Samples / September 16, 2026
Original Idea

"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

Target: Bolt Category: Consumer App Downstream: Vibe Coding Prompt Pack
D
David

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:

  1. User opens the discovery screen → client calls the candidate discovery stored procedure, passing the user's current location coordinates and active filters.
  2. 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").
  3. User swipes right (like) or left (pass) → client calls the swipe stored procedure with the target user ID and direction.
  4. 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.
  5. Supabase Realtime delivers the match notification to both clients.
  6. 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:

  1. 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).
  2. Retrieve the calling user's own profile to get their gender and orientation (used for reciprocal filter logic).
  3. 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.
  4. Order results by ST_Distance ascending (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).
  5. 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:

  1. Accept inputs: calling user ID (from auth.uid()), target user ID, direction (like or pass), optional super-like flag.
  2. Check and decrement the daily quota counter. If exhausted, return a quota-exhausted error — do not record the swipe.
  3. Insert a row into the swipes table (caller, target, direction, timestamp, super-like flag).
  4. 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.
  5. 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 anon key is public (it is safe to expose; RLS is the enforcement layer). The service_role key 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: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-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 main branch triggers an automatic production deploy. Pull request branches get preview deployments automatically.
  • Database migrations: Managed with the Supabase CLI. The developer runs supabase db push from a local machine (or CI runner if the team scales) to apply migrations to the production Supabase project. Migrations are stored in the repo under supabase/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_DWithin query returns correct results with known test coordinates before launch. Run EXPLAIN ANALYZE to 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 main deploys 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/callback as 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/callback as 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 URL
  • VITE_SUPABASE_ANON_KEY — Supabase project dashboard → Settings → API → Project API Keys → anon public
  • VITE_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 key
  • STRIPE_WEBHOOK_SIGNING_SECRET — Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret
  • SUPABASE_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 using VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY from 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, sets sendDefaultPii: false, and registers a beforeSend hook that removes any message-typed breadcrumb content and strips the body field from any request context before the event is sent.
  • Exports captureError(error: unknown, context?: Record<string, string>): void — wraps Sentry.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 fields code: string and message: 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 dev starts the local server with no console errors.
  • Navigating to each route renders the correct placeholder page title.
  • supabaseClient import resolves and logs the project URL in dev mode.
  • Sentry is initialized; a test captureError call 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, throws AuthError with code: 'oauth_redirect_failed'.
  • signInWithFacebook(): Promise<void> — same shape as signInWithGoogle.
  • signOut(): Promise<void> — signs out and clears the local session. On failure, throws AuthError with code: 'signout_failed'.
  • getSession(): Promise<Session | null> — returns the current session or null. Session is 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 }.
  • AuthProvider component: subscribes to onAuthStateChange on mount, stores the result in state, sets loading: false once 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 outside AuthProvider.

src/components/ProtectedRoute.tsx (replaces Phase 1 stub)

  • Reads { session, loading } from useAuth().
  • While loading is true, renders a centered full-screen spinner.
  • If session is 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 profiles table.
  • 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:

  • GIST index on profiles.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 NULL and suspended = 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) and active = true and deleted_at IS NULL.
  • No client write policies.

conversations

  • Read: authenticated users can select rows where the linked match_id references a match they participate in, active = true, and deleted_at IS NULL (joined via matches).
  • 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, and deleted = 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() or blocked_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 messages as User A returns zero rows from User B's conversations.
  • Querying reports as any authenticated user returns zero rows (insert succeeds; read is denied).
  • Direct insert into swipes as 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):

  1. Confirm p_auth_uid = auth.uid() — rejects mismatched callers.
  2. Calculate age from p_date_of_birth using CURRENT_DATE. If age < 18, return { error: { code: 'UNDERAGE', message: '...' } }.
  3. Validate p_display_name is non-null and 1–50 characters.
  4. Validate p_bio is null or ≤ 500 characters.
  5. Validate p_gender_identity and p_sexual_orientation are non-null and non-empty.
  6. Validate p_interest_tags has 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 the create_profile RPC. On ProcedureError.code === 'UNDERAGE', throws ValidationError. On other procedure errors, maps to the matching AppError subclass. On success, returns void.
  • getMyProfile(): Promise<Profile> — selects the authenticated user's own profile row. Throws NotFoundError if 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 procedure update_location(p_lat float8, p_lng float8) (defined in the same migration as a simple SECURITY DEFINER updater for the location column 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):

  1. Date of birth — date picker. Client calculates display age only. "Next" is disabled if calculated age < 18 (UX convenience; server enforces the real gate).
  2. Display name + bio — text inputs with character count.
  3. Gender identity + sexual orientation — select menus.
  4. Interest tags — multi-select, max 10.
  5. Submit — calls createProfile. On ValidationError with 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.
  • updateProfileLocation stores a valid PostGIS point; EXPLAIN ANALYZE on a ST_DWithin query 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 the profile-photos bucket. The auth UID comes from the active session (retrieved from supabaseClient.auth.getSession()). Returns the public URL of the uploaded file on success. Throws ValidationError if the file's MIME type is not image/jpeg, image/png, or image/webp, or if the file size exceeds 10 MB (client-side gate; Storage policy is the enforcing layer). Throws PermissionError if the Storage RLS policy rejects the upload. Throws ServiceError on transient failure. Same error handling contract as src/lib/profile.ts.
  • deletePhoto(publicUrl: string): Promise<void> — derives the storage path from the public URL and removes the object. Throws ServiceError on 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 the update_profile RPC. Maps procedure errors to AppError subclasses.

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 uploadPhoto then updateProfile with the new URL appended. Delete triggers deletePhoto then updateProfile with the URL removed. Both show per-slot loading and error states.
  • Display name, bio, interest tags, gender, orientation — editable fields. "Save changes" calls updateProfile with the full current photo URL array plus updated text fields.
  • Error states show the AppError.message inline 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_urls array 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):

  1. Validate inputs: coordinates in range, p_radius_km between 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: '...' } }.
  2. Check the calling user's quota in swipe_quotas for today. If swipes_used >= quota_limit, return { data: { candidates: [], quota_exhausted: true } }.
  3. Retrieve the calling user's auth_uid as caller_id from auth.uid(). Retrieve caller_gender and caller_orientation from profiles where auth_uid = caller_id.
  4. Query profiles with 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 NULL and suspended = false.
    • photo_urls IS NOT NULL and array_length(photo_urls, 1) > 0.
    • Age (calculated from date_of_birth) between p_age_min and p_age_max.
    • gender_identity = ANY(p_gender_prefs) if p_gender_prefs is non-empty.
    • Orientation compatibility: the candidate's sexual_orientation must be compatible with the caller's gender. (A candidate who is exclusively interested in a gender that does not include caller_gender is 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 in swipes where swiper_id = caller_id AND target_id = profiles.auth_uid.
    • Anti-join against blocks: no row exists in blocks where (blocker_id = caller_id AND blocked_id = profiles.auth_uid) OR (blocker_id = profiles.auth_uid AND blocked_id = caller_id).
    • If p_interest_tags is non-empty: profiles.interest_tags && p_interest_tags (array overlap, not exact match).
  5. Order results: boost_expires_at > now() descending (boosted profiles first), then ST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography) ascending.
  6. Apply LIMIT p_page_size OFFSET p_page_offset.
  7. For each result row, compute: age as integer years from date_of_birth, distance_label as 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, or location in the output.
  8. Return { data: { candidates: [...], quota_exhausted: false } }. Each candidate object matches the Candidate interface in src/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):

  1. Validate p_direction is one of 'like', 'pass', 'super_like'. Validate p_target_id != auth.uid(). On failure, return { error: { code: 'VALIDATION', message: '...' } }.
  2. Check that the target profile exists, is not suspended, and is not soft-deleted. If not found, return { error: { code: 'NOT_FOUND', message: '...' } }.
  3. Lock and read the swipe_quotas row for user_id = auth.uid() and today's date using SELECT ... FOR UPDATE. If no row exists, insert one with quota_limit copied from profiles.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.
  4. If swipes_used >= quota_limit, return { data: { match_created: false, match_id: null, quota_exhausted: true } } without inserting anything.
  5. Increment swipes_used by 1.
  6. Insert into swipes (swiper_id, target_id, direction, super_like, created_at). On uniqueness constraint violation (duplicate swipe), return { error: { code: 'DUPLICATE', message: '...' } }.
  7. If p_direction is 'like' or '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: a. Insert into matches (participant_a_id, participant_b_id) where A is the earlier swiper (the target, who liked first) and B is the caller. b. Insert into conversations (match_id) for the new match. c. Insert into messages (conversation_id, sender_id, content) a system message: sender_id is auth.uid(), content is 'You matched! Say hello 👋'. d. Return { data: { match_created: true, match_id: '<new match uuid>', quota_exhausted: false } }.
  8. 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 the discover_candidates RPC. Maps procedure errors to AppError subclasses. Returns DiscoveryResult on success.
  • recordSwipe(params: { targetId: string; direction: SwipeDirection; superLike?: boolean }): Promise<SwipeResult> — calls the record_swipe RPC. On DUPLICATE code, throws DuplicateActionError. On QUOTA_EXHAUSTED in the result data (not an error code), returns the result with quota_exhausted: true — the caller handles the paywall display. Maps other procedure errors to AppError subclasses.

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.

  • FilterPreferences interface: { radiusKm: number; ageMin: number; ageMax: number; genderPrefs: string[]; orientationFilter: string; interestTags: string[] }.
  • getFilterPreferences(userId: string): FilterPreferences — reads from localStorage, returns defaults (radiusKm: 50, ageMin: 18, ageMax: 99, genderPrefs: [], orientationFilter: '', interestTags: []) if no stored value found.
  • setFilterPreferences(userId: string, prefs: FilterPreferences): void — writes to localStorage.

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 recordSwipe and removes the card from the local deck.
  • If SwipeResult.match_created is true, shows an in-app "It's a Match!" modal with a button to open the chat (navigates to /chat/:matchId).
  • If SwipeResult.quota_exhausted is true, renders the Paywall Modal (a separate component stub — Stripe checkout is wired in Phase 8).
  • If DiscoveryResult.quota_exhausted is 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_candidates called with valid coordinates returns candidates matching all filter conditions. No raw coordinates appear in the result.
  • discover_candidates called with coordinates outside valid ranges returns a VALIDATION error.
  • record_swipe called more times than the daily quota returns quota_exhausted: true and does not insert additional swipe rows.
  • record_swipe called twice for the same target returns a DUPLICATE error.
  • 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:

  1. Validate p_blocked_id != auth.uid().
  2. Insert into blocks (blocker_id, blocked_id). On uniqueness violation, treat as no-op and continue.
  3. Find any active match between auth.uid() and p_blocked_id. If found, set matches.active = false and matches.deleted_at = now(), and set conversations.deleted_at = now() for the linked conversation.
  4. Return { data: { success: true } }.

unmatch(p_match_id uuid) — SECURITY DEFINER, runs in a single transaction:

  1. Verify the match exists and auth.uid() is a participant. If not, return { error: { code: 'NOT_FOUND', message: '...' } }.
  2. Set matches.active = false and matches.deleted_at = now().
  3. Set conversations.deleted_at = now() for the linked conversation.
  4. Return { data: { success: true } }.

src/lib/matches.ts

  • getMatches(): Promise<Match[]> — selects all active match rows where auth.uid() is a participant, ordered by created_at descending. Returns an array of Match objects.
  • unmatch(matchId: string): Promise<void> — calls the unmatch RPC. Maps procedure errors to AppError subclasses.
  • blockUser(blockedId: string): Promise<void> — calls the block_user RPC. Maps procedure errors to AppError subclasses. Same error handling contract as src/lib/profile.ts.

src/lib/messages.ts

  • getMessages(conversationId: string): Promise<Message[]> — selects all non-deleted messages for the conversation ordered by sent_at ascending. Throws PermissionError if RLS rejects (match is no longer active or user is not a participant).
  • sendMessage(conversationId: string, content: string): Promise<Message> — inserts a message row with sender_id = auth.uid(), content trimmed and validated as non-empty (≤ 2000 characters — client-side gate; no stored procedure wraps this insert, RLS is the enforcing layer). Throws ValidationError if content is empty or over the limit. Throws PermissionError if RLS rejects. Returns the inserted row.
  • getConversationIdForMatch(matchId: string): Promise<string> — selects the conversations.id for the given match_id. Throws NotFoundError if not found.
  • subscribeToMessages(conversationId: string, onMessage: (message: Message) => void): () => void — subscribes to Realtime INSERT events on messages filtered to conversation_id = conversationId. Returns an unsubscribe function. Reconnection and gap-filling behavior: the subscription's onError callback calls getMessages to re-fetch from the last known sent_at timestamp, then re-subscribes.
  • subscribeToMatchNotifications(onMatch: (match: Match) => void): () => void — subscribes to Realtime INSERT events on matches filtered to participant_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 where reporter_id = auth.uid(). Throws PermissionError if RLS rejects. Same error handling contract as src/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.
  • subscribeToMatchNotifications fires 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:

  1. Read the raw request body and the Stripe-Signature header.
  2. 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.
  3. 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).
  4. Extract the client_reference_id from the session object — this is the user's auth_uid, set when creating the checkout session in step 5 of this phase.
  5. Using SUPABASE_SERVICE_ROLE_KEY, update the profiles row where auth_uid = client_reference_id: set subscription_tier = 'premium' and update updated_at = now(). Also upsert a swipe_quotas row for today setting quota_limit = 999999.
  6. 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 Function create-checkout-session (defined below) to get a Stripe Checkout URL. Returns the URL string. Throws ServiceError on failure. Same error handling contract as src/lib/profile.ts.

supabase/functions/create-checkout-session/index.ts

Supabase Edge Function. Invoked by the authenticated client.

Processing sequence:

  1. Validate the request has a valid Supabase JWT. Extract auth.uid() from the JWT claims. If invalid, return HTTP 401.
  2. 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_id set to the user's auth_uid, success_url and cancel_url pointing back to the app's production domain.
  3. 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_limit reflects 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):

  1. Validate the request has a valid Supabase JWT. Extract user_id = auth.uid(). If invalid, return HTTP 401.
  2. Anonymize profile PII: Using SUPABASE_SERVICE_ROLE_KEY, 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(). Safe to re-run: already-null fields remain null.
  3. Delete Storage photos: List all objects in the profile-photos bucket under the path {user_id}/. Delete each object. If the folder is already empty, this step succeeds immediately.
  4. Anonymize message sender IDs: Update messages where sender_id = user_id, setting sender_id to 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 condition sender_id = user_id won't match after the first run).
  5. 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.
  6. 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 the gdpr-erase Edge Function. On success, calls signOut() then navigates to /login. On failure, throws ServiceError with the message from the function response. Same error handling contract as src/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 profiles row 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: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-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 ANALYZE on discover_candidates with 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.completed event 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_swipe 11 times as a free-tier user and confirm the 11th call returns quota_exhausted: true with 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 beforeSend hook that removes any breadcrumb entries whose category is 'console' and whose message field contains content, and strips the body field from any request context attached to the event. The hook must not alter or drop the event itself — only scrub those specific fields.

Also export captureError(error: unknown, context?: Record<string, string>): void — a wrapper around the Sentry SDK's captureException call that attaches the context object as extra data when provided.

src/lib/errors.ts

Define the shared error vocabulary for the entire app. All modules throw or return these types; no module surfaces raw Supabase or PostgreSQL error text to the caller.

Export the following, all as named exports:

  • AppError — base class with fields code: string and message: string. Constructor accepts both.
  • ValidationError extends AppError
  • QuotaExhaustedError extends AppError
  • DuplicateActionError extends AppError
  • PermissionError extends AppError
  • AuthError extends AppError
  • NotFoundError extends AppError
  • ServiceError extends AppError

src/main.tsx

App entry point. Import and call initSentry() as the very first statement, before any React import or render call. Then wrap <App /> in both <QueryClientProvider> (with a new QueryClient instance) and <AuthProvider> (stub — imported from src/contexts/AuthContext.tsx, defined below). Mount to document.getElementById('root').

src/contexts/AuthContext.tsx

Stub only at this phase. Export AuthProvider as a component that renders its children unconditionally. Export useAuth as a hook that returns { session: null, loading: false }. Both will be replaced in Prompt 2.

src/components/ProtectedRoute.tsx

Stub only at this phase. Render <Outlet /> unconditionally. This will be replaced in Prompt 2.

src/App.tsx

Own the React Router v6 router and all route definitions. Define the following routes:

Path Component Protected
/ DiscoveryPage Yes
/login LoginPage No
/onboarding OnboardingPage Yes
/matches MatchListPage Yes
/chat/:matchId ChatPage Yes
/profile ProfileEditorPage Yes
/settings SettingsPage Yes

Protected routes are wrapped in <ProtectedRoute> using React Router's layout route pattern. Unprotected routes render their page component directly.

Placeholder page components

Create one file per page. Each renders a single <h1> containing the page name and nothing else. Files:

  • src/pages/DiscoveryPage.tsx — heading: "Discovery"
  • src/pages/LoginPage.tsx — heading: "Login"
  • src/pages/OnboardingPage.tsx — heading: "Onboarding"
  • src/pages/MatchListPage.tsx — heading: "Matches"
  • src/pages/ChatPage.tsx — heading: "Chat"
  • src/pages/ProfileEditorPage.tsx — heading: "Profile Editor"
  • src/pages/SettingsPage.tsx — heading: "Settings"

Constraints:

  • supabaseClient must be exported from src/lib/supabase.ts as a named export, not a default export.
  • captureError must be exported from src/lib/sentry.ts as a named export.
  • All AppError subclasses must be individually named exports from src/lib/errors.ts.
  • initSentry() must be called before ReactDOM.createRoot or any equivalent mount call.
  • Do not add any other routes, pages, or components beyond what is listed above.

Check before continuing (do this yourself, do not paste it):

  • Navigate to each route (e.g., /, /login, /matches) and confirm the correct <h1> heading renders with no console errors.
  • Open your browser's network tab and confirm requests are going to your Supabase project URL, not a placeholder.
  • If something is wrong: tell Bolt "The app has a console error on startup — fix it without changing the file structure or adding new components."

Prompt 2

Run this after Prompt 1 is complete.

This prompt wires up real Google and Facebook sign-in, session management across the whole app, and the protected route guard. It also creates the database trigger that automatically creates a profile row the first time a user signs in. When this is done, clicking "Continue with Google" or "Continue with Facebook" will complete a real OAuth flow and land you inside the protected app.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompt 1 is complete: the project skeleton exists with src/lib/supabase.ts (exports supabaseClient), src/lib/errors.ts (exports AppError and subclasses), src/lib/sentry.ts (exports captureError), src/contexts/AuthContext.tsx (stub), src/components/ProtectedRoute.tsx (stub), and placeholder pages for all routes. Now build authentication and session management.

Do not build: database schema, profile data fetching, onboarding UI, or any page beyond LoginPage.


Files to create or replace

src/lib/auth.ts

All auth operations in the app go through this module exclusively. Nothing else in the codebase calls supabaseClient.auth directly.

Export the following named functions:

  • signInWithGoogle(): Promise<void> — initiates the Supabase Google OAuth redirect. On any SDK error, throws AuthError with code: 'oauth_redirect_failed' and a human-readable message. Import AuthError from src/lib/errors.ts.
  • signInWithFacebook(): Promise<void> — same shape as signInWithGoogle, same error type, code: 'oauth_redirect_failed'.
  • signOut(): Promise<void> — signs out the current user and clears the local session. On SDK error, throws AuthError with code: 'signout_failed'.
  • getSession(): Promise<Session | null> — returns the current Supabase session or null. Re-export the Session type from the Supabase JS SDK so callers import it from here.
  • onAuthStateChange(callback: (session: Session | null) => void): () => void — registers an auth state change listener using the Supabase SDK. Returns an unsubscribe function. The callback receives the new session (or null on sign-out) whenever the auth state changes.

src/contexts/AuthContext.tsx (replaces Prompt 1 stub)

Provide session state to the entire app.

Context value shape: { session: Session | null; loading: boolean }.

AuthProvider component behavior:

  • On mount, call getSession() to get the initial session and set loading: false.
  • Subscribe to onAuthStateChange and update the stored session in state on every change.
  • Unsubscribe on unmount.
  • While the initial getSession() call is in flight, loading is true.

Export useAuth(): { session: Session | null; loading: boolean } — a hook that reads the context. If called outside AuthProvider, throw an error with the message 'useAuth must be used within AuthProvider'.

src/components/ProtectedRoute.tsx (replaces Prompt 1 stub)

Read { session, loading } from useAuth().

  • While loading is true: render a centered full-screen spinner. Do not redirect or render children.
  • When loading is false and session is null: redirect to /login using React Router's <Navigate>.
  • When loading is false and session is not null: render <Outlet />.

src/pages/LoginPage.tsx (replaces placeholder)

Replace the placeholder heading with a real login screen. Render:

  • A "Continue with Google" button that calls signInWithGoogle() on click.
  • A "Continue with Facebook" button that calls signInWithFacebook() on click.
  • If either call throws an AuthError, render the error's message field as inline text below the buttons.
  • No navigation logic — the OAuth redirect takes over on success.

supabase/migrations/0001_auth_trigger.sql

SQL migration that installs a PostgreSQL trigger on auth.users. On INSERT (new user), it calls a SECURITY DEFINER function named create_skeleton_profile().

create_skeleton_profile() must:

  • Insert a row into profiles with auth_uid set to the new user's ID and created_at set to now(). All other columns take their column defaults (null or the default value defined in Phase 3's schema migration).
  • If the insert fails for any reason (including a duplicate key, which should not occur but is guarded), catch the exception and log it using RAISE LOG without re-raising — the trigger must never block Supabase Auth from completing the sign-in.
  • The profiles table is created in a later migration. Use IF EXISTS guards or EXECUTE with dynamic SQL so this trigger function installs without error even before the profiles table exists. When the table does not exist, the function should log a notice and return without error.

This migration is stored in supabase/migrations/0001_auth_trigger.sql and applied via the Supabase CLI (supabase db push) from the developer's machine.


Constraints:

  • signInWithGoogle and signInWithFacebook must use the Supabase SDK's OAuth sign-in with redirect — not a popup.
  • No component other than AuthProvider may call onAuthStateChange or getSession.
  • ProtectedRoute must show a spinner (not a blank screen) while loading is true.
  • Do not modify src/lib/supabase.ts, src/lib/errors.ts, or src/lib/sentry.ts.
  • Do not build profile fetching, onboarding routing logic, or post-login redirection beyond what React Router's existing route structure already provides.

Check before continuing (do this yourself, do not paste it):

  • Click "Continue with Google" and confirm you are redirected to Google's OAuth screen, then returned to the app with a session.
  • Click "Continue with Facebook" and confirm the same flow completes successfully.
  • Visiting / without a session redirects to /login. Visiting / after signing in renders the "Discovery" placeholder heading.
  • After calling sign-out (you can test this via Bolt's console), the session clears and the router redirects to /login.
  • If something is wrong: tell Bolt "The OAuth flow is not completing — check the Supabase redirect URL configuration and fix the auth module without changing other files."

Prompt 3

Run this after Prompt 2 is complete.

This prompt creates every database table, index, and access policy the app depends on. No application code changes — this is entirely SQL migration files. When it's done, the full database structure is in place and access rules are enforced at the database level, so no later prompt can accidentally read data it shouldn't.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1 and 2 are complete: the project skeleton and OAuth authentication are working. Now create all database tables, indexes, and Row Level Security policies. This prompt is entirely SQL migration files — do not modify any TypeScript files.

Do not build: stored procedures, application logic, or any UI changes.


Files to create

supabase/migrations/0002_schema.sql

Create the following tables in this exact order (to satisfy foreign key dependencies). Column names listed here are the canonical reference — all later stored procedures, RLS policies, and TypeScript types must match these exactly.

profiles

auth_uid           uuid PRIMARY KEY REFERENCES auth.users(id)
display_name       text
date_of_birth      date
gender_identity    text
sexual_orientation text
interest_tags      text[]
bio                text
location           geography(Point, 4326)
photo_urls         text[]
subscription_tier  text NOT NULL DEFAULT 'free'
boost_expires_at   timestamptz
suspended          boolean NOT NULL DEFAULT false
created_at         timestamptz NOT NULL DEFAULT now()
updated_at         timestamptz NOT NULL DEFAULT now()
deleted_at         timestamptz

swipe_quotas

id           uuid PRIMARY KEY DEFAULT gen_random_uuid()
user_id      uuid NOT NULL REFERENCES profiles(auth_uid)
quota_date   date NOT NULL
swipes_used  integer NOT NULL DEFAULT 0
quota_limit  integer NOT NULL
reset_at     timestamptz NOT NULL
UNIQUE(user_id, quota_date)

swipes

id          uuid PRIMARY KEY DEFAULT gen_random_uuid()
swiper_id   uuid NOT NULL REFERENCES profiles(auth_uid)
target_id   uuid NOT NULL REFERENCES profiles(auth_uid)
direction   text NOT NULL
super_like  boolean NOT NULL DEFAULT false
created_at  timestamptz NOT NULL DEFAULT now()
UNIQUE(swiper_id, target_id)

matches

id                uuid PRIMARY KEY DEFAULT gen_random_uuid()
participant_a_id  uuid NOT NULL REFERENCES profiles(auth_uid)
participant_b_id  uuid NOT NULL REFERENCES profiles(auth_uid)
active            boolean NOT NULL DEFAULT true
created_at        timestamptz NOT NULL DEFAULT now()
deleted_at        timestamptz

conversations

id          uuid PRIMARY KEY DEFAULT gen_random_uuid()
match_id    uuid NOT NULL UNIQUE REFERENCES matches(id)
created_at  timestamptz NOT NULL DEFAULT now()
deleted_at  timestamptz

messages

id               uuid PRIMARY KEY DEFAULT gen_random_uuid()
conversation_id  uuid NOT NULL REFERENCES conversations(id)
sender_id        uuid NOT NULL REFERENCES profiles(auth_uid)
content          text NOT NULL
sent_at          timestamptz NOT NULL DEFAULT now()
deleted          boolean NOT NULL DEFAULT false

blocks

id          uuid PRIMARY KEY DEFAULT gen_random_uuid()
blocker_id  uuid NOT NULL REFERENCES profiles(auth_uid)
blocked_id  uuid NOT NULL REFERENCES profiles(auth_uid)
created_at  timestamptz NOT NULL DEFAULT now()
UNIQUE(blocker_id, blocked_id)

reports

id             uuid PRIMARY KEY DEFAULT gen_random_uuid()
reporter_id    uuid NOT NULL REFERENCES profiles(auth_uid)
entity_type    text NOT NULL
entity_id      uuid NOT NULL
reason         text NOT NULL
detail         text
created_at     timestamptz NOT NULL DEFAULT now()
review_status  text NOT NULL DEFAULT 'pending'

supabase/migrations/0003_indexes.sql

Create the following indexes. Use CREATE INDEX CONCURRENTLY for all B-tree indexes (non-blocking). The GIST index on profiles.location must not use CONCURRENTLY — PostGIS GIST indexes do not support concurrent creation.

  • GIST index on profiles(location).
  • Composite B-tree index on swipes(target_id, swiper_id) — this is the reverse lookup index for mutual match detection; the forward direction is already covered by the UNIQUE(swiper_id, target_id) constraint.
  • Composite B-tree index on matches(participant_a_id, participant_b_id).
  • B-tree index on messages(conversation_id).

Do not create redundant indexes for columns already covered by PRIMARY KEY or UNIQUE constraints.

supabase/migrations/0004_rls.sql

Enable RLS on every table: profiles, swipe_quotas, swipes, matches, conversations, messages, blocks, reports.

Create the following policies:

profiles

  • SELECT policy for authenticated users: deleted_at IS NULL AND suspended = false.
  • UPDATE policy for authenticated users: auth_uid = auth.uid() (own row only).
  • No INSERT policy from client (the auth trigger handles it). No DELETE policy from client.

swipe_quotas — no client policies. Access is exclusively through SECURITY DEFINER procedures.

swipes — no client policies. Access is exclusively through SECURITY DEFINER procedures.

matches

  • SELECT policy for authenticated users: (auth.uid() = participant_a_id OR auth.uid() = participant_b_id) AND active = true AND deleted_at IS NULL.
  • No client INSERT, UPDATE, or DELETE policies.

conversations

  • SELECT policy for authenticated users: the conversation's match_id must reference a match where auth.uid() IN (participant_a_id, participant_b_id), the match's active = true, and the conversation's deleted_at IS NULL. Implement this as a subquery join against matches.
  • No client INSERT, UPDATE, or DELETE policies.

messages

  • SELECT policy for authenticated users: auth.uid() must be a participant in the linked conversation's match; the match must be active = true; the conversation must have deleted_at IS NULL; and the message's own deleted = false. Implement using a subquery join through conversations to matches.
  • INSERT policy for authenticated users: sender_id = auth.uid(), and the same active/non-deleted conditions on the linked conversation and match apply. Implement with the same subquery join pattern.
  • No UPDATE or DELETE policies from client.

blocks

  • SELECT policy for authenticated users: blocker_id = auth.uid() OR blocked_id = auth.uid().
  • No client INSERT policy (insert is via stored procedure).

reports

  • INSERT policy for authenticated users: reporter_id = auth.uid().
  • No SELECT policy for authenticated users (admin-only via service-role key).

Constraints:

  • Apply migrations in numeric order: 0002, 0003, 0004.
  • The migration file for RLS (0004) must call ALTER TABLE <table> ENABLE ROW LEVEL SECURITY before creating any policy on that table.
  • Do not modify any TypeScript files.
  • Do not create any stored procedures in this prompt — those come in later prompts.

Check before continuing (do this yourself, do not paste it):

  • All three migration files apply to your dev Supabase project with no errors (run supabase db push from your local machine or confirm via the Supabase SQL editor).
  • In the Supabase dashboard → Database → Extensions, PostGIS is listed as enabled.
  • In the Supabase dashboard → Database → Tables, all eight tables are present.
  • If something is wrong: tell Bolt "Migration [filename] failed with an error — fix only that migration file without changing the others."

Prompt 4

Run this after Prompt 3 is complete.

This prompt builds the onboarding flow — the multi-step form new users complete after signing in for the first time, including the server-enforced age gate. It also creates the shared TypeScript types that every later prompt uses, and the stored procedures for creating and updating a user's location. When this is done, a new user can complete onboarding and have a real profile row in the database.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–3 are complete: the project skeleton, OAuth authentication, and the full database schema with RLS are working. Now build the shared TypeScript types, the profile creation stored procedure, and the onboarding UI.

Do not build: photo upload, profile editing beyond initial creation, discovery, or chat.


Files to create or replace

src/lib/types.ts

Canonical TypeScript types for all database shapes. Every other module imports from here — never re-declares inline.

export type SubscriptionTier = 'free' | 'premium';
export type SwipeDirection = 'like' | 'pass' | 'super_like';
export type ReportReason = 'spam' | 'harassment' | 'underage' | 'inappropriate' | 'other';
export type ReviewStatus = 'pending' | 'reviewed' | 'actioned';

export interface Profile {
  auth_uid: string;
  display_name: string | null;
  date_of_birth: string | null;        // ISO date string 'YYYY-MM-DD'
  gender_identity: string | null;
  sexual_orientation: string | null;
  interest_tags: string[] | null;
  bio: string | null;
  photo_urls: string[] | null;
  subscription_tier: SubscriptionTier;
  boost_expires_at: string | null;     // ISO timestamp string
  suspended: boolean;
  created_at: string;
  updated_at: string;
  deleted_at: string | null;
}

export interface Match {
  id: string;
  participant_a_id: string;
  participant_b_id: string;
  active: boolean;
  created_at: string;
  deleted_at: string | null;
}

export interface Message {
  id: string;
  conversation_id: string;
  sender_id: string;
  content: string;
  sent_at: string;
  deleted: boolean;
}

export interface Candidate {
  profile_id: string;
  display_name: string;
  age: number;
  distance_label: string;
  interest_tags: string[];
  photo_urls: string[];
  boost_active: boolean;
}

export interface DiscoveryResult {
  candidates: Candidate[];
  quota_exhausted: boolean;
}

export interface SwipeResult {
  match_created: boolean;
  match_id: string | null;
  quota_exhausted: boolean;
}

export interface ProcedureError {
  code: 'UNDERAGE' | 'VALIDATION' | 'DUPLICATE' | 'QUOTA_EXHAUSTED' | 'NOT_FOUND' | 'PERMISSION' | 'INTERNAL';
  message: string;
}

All stored procedures return a jsonb object with either a data field (success) or an error field of type ProcedureError (failure). This envelope is the universal contract between SQL and TypeScript that all later modules depend on.

supabase/migrations/0005_create_profile_proc.sql

Creates the SECURITY DEFINER stored procedure create_profile(p_auth_uid uuid, p_display_name text, p_date_of_birth date, p_gender_identity text, p_sexual_orientation text, p_interest_tags text[], p_bio text) returning jsonb.

Validation sequence — each check returns { "error": { "code": "...", "message": "..." } } on failure with no writes:

  1. Confirm p_auth_uid = auth.uid(). If not, return { "error": { "code": "PERMISSION", "message": "Caller mismatch" } }.
  2. 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" } }.
  3. Validate p_display_name is non-null and between 1 and 50 characters. On failure, return { "error": { "code": "VALIDATION", "message": "Display name must be 1–50 characters" } }.
  4. Validate p_bio is null or LENGTH(p_bio) <= 500. On failure, return { "error": { "code": "VALIDATION", "message": "Bio must be 500 characters or fewer" } }.
  5. Validate p_gender_identity is non-null and non-empty. On failure, return { "error": { "code": "VALIDATION", "message": "Gender identity is required" } }.
  6. Validate p_sexual_orientation is non-null and non-empty. On failure, return { "error": { "code": "VALIDATION", "message": "Sexual orientation is required" } }.
  7. Validate p_interest_tags is null or array_length(p_interest_tags, 1) <= 10. On failure, return { "error": { "code": "VALIDATION", "message": "Maximum 10 interest tags allowed" } }.

On all validations passing: update the skeleton profile row where auth_uid = p_auth_uid, setting all supplied fields and updated_at = now(). Return { "data": { "success": true } }.

supabase/migrations/0006_update_location_proc.sql

Creates update_location(p_lat float8, p_lng float8) returning jsonb as a SECURITY DEFINER procedure.

Validate: p_lat is between -90 and 90 inclusive; p_lng is between -180 and 180 inclusive. On failure, return { "error": { "code": "VALIDATION", "message": "Invalid coordinates" } }.

On success: update profiles.location = ST_MakePoint(p_lng, p_lat)::geography where auth_uid = auth.uid(). Set updated_at = now(). Return { "data": { "success": true } }.

src/lib/profile.ts

Thin wrapper around profile stored procedures and queries. Import supabaseClient from src/lib/supabase.ts. Import error classes from src/lib/errors.ts. Import Profile and ProcedureError from src/lib/types.ts.

Error mapping contract (applies to all functions in this module and is the inherited standard for all later data modules): when the Supabase SDK returns an error (not a procedure-level ProcedureError, but an SDK-level transport or auth error), map it to the appropriate AppError subclass — AuthError for session errors, PermissionError for RLS rejections, ServiceError for all others — and throw it. When the RPC returns { error: ProcedureError }, map code to subclass: UNDERAGE → ValidationError, VALIDATION → ValidationError, DUPLICATE → DuplicateActionError, QUOTA_EXHAUSTED → QuotaExhaustedError, NOT_FOUND → NotFoundError, PERMISSION → PermissionError, INTERNAL → ServiceError. Preserve the procedure's message string in the thrown error.

Export the following named functions:

  • createProfile(params: { displayName: string; dateOfBirth: string; genderIdentity: string; sexualOrientation: string; interestTags: string[]; bio: string }): Promise<void> — calls the create_profile RPC with p_auth_uid set to the current session's user ID (retrieved from supabaseClient.auth.getSession()). Maps procedure and SDK errors as above. Returns void on success.
  • getMyProfile(): Promise<Profile> — selects the authenticated user's own profile row using supabaseClient.from('profiles').select('*').eq('auth_uid', <session user id>).single(). Throws NotFoundError if no row is returned (the auth trigger may have failed; calling code redirects to onboarding). Maps SDK errors as above.
  • updateProfileLocation(lat: number, lng: number): Promise<void> — calls the update_location RPC. Maps errors as above. Returns void on success.

src/pages/OnboardingPage.tsx (replaces placeholder)

Multi-step form rendered as a single page with internal step state. Steps in order:

Step 1 — Date of birth: Date input. On "Next," calculate age client-side. If calculated age < 18, show an inline message "You must be 18 or older to use Aura" and disable the Next button. This is a UX convenience only — the stored procedure enforces the real gate. Do not proceed to Step 2 if client-side age < 18.

Step 2 — Display name and bio: Text input for display name (max 50 characters, show character count), textarea for bio (max 500 characters, show character count).

Step 3 — Gender identity and sexual orientation: Two select/dropdown menus. Options:

  • Gender identity: "Man", "Woman", "Non-binary", "Other"
  • Sexual orientation: "Straight", "Gay", "Lesbian", "Bisexual", "Pansexual", "Other" Both are required; Next is disabled until both are selected.

Step 4 — Interest tags: A multi-select UI showing these fixed tags: "Music", "Film", "Travel", "Fitness", "Food", "Art", "Gaming", "Reading", "Outdoors", "Tech". Maximum 10 selectable (selecting an 11th does nothing). Shows selected count.

Step 5 — Submit: A "Create Profile" button. On click, call createProfile with all collected values. While the call is in progress, show a loading state and disable the button. On ValidationError with code matching underage, render "You must be 18 or older to use Aura" with no retry path (the user cannot proceed). On other ValidationError, show the error message with a "Go back" link that returns to the relevant step. On ServiceError, show the message and a "Try again" button. On success, navigate to /profile.

Back buttons on steps 2–4 return to the previous step.


Constraints:

  • src/lib/types.ts must export every interface and type exactly as specified — field names, field types, and nullability must match exactly.
  • src/lib/profile.ts must follow the error mapping contract exactly; no raw Supabase error strings may reach calling code.
  • Do not modify src/lib/supabase.ts, src/lib/auth.ts, src/lib/errors.ts, src/lib/sentry.ts, or any migration files from Prompts 1–3.
  • Do not build photo upload in this prompt — the profile editor in the next prompt handles that.

Check before continuing (do this yourself, do not paste it):

  • Sign in, complete all five onboarding steps with a date of birth indicating age 18, and confirm the app navigates to /profile (the placeholder) after submission.
  • Repeat the flow with a date of birth indicating age 17 and confirm the rejection message appears and the form does not submit.
  • In the Supabase dashboard → Table Editor → profiles, confirm the row for your test user has display_name, date_of_birth, and other fields populated.
  • If something is wrong: tell Bolt "The profile creation RPC is returning an error — show me the raw response from the RPC call without changing any other code."

Prompt 5

Run this after Prompt 4 is complete.

This prompt builds photo management and the full profile editor. Users can upload up to 6 photos directly to Supabase Storage, remove photos, and edit their display name, bio, and other fields. The 6-photo limit is enforced by the database, not the browser. When this is done, users have a working profile editor they can return to at any time.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–4 are complete: the project skeleton, OAuth, database schema with RLS, shared types, and onboarding are all working. Now build photo upload, Storage integration, and the profile editor.

Do not build: discovery, swipe logic, chat, or matching.


Files to create or replace

supabase/migrations/0007_update_profile_proc.sql

Creates update_profile(p_display_name text, p_bio text, p_interest_tags text[], p_gender_identity text, p_sexual_orientation text, p_photo_urls text[]) returning jsonb as a SECURITY DEFINER procedure for the authenticated user's own profile.

Validation (each returns { "error": { "code": "VALIDATION", "message": "..." } } on failure, no writes):

  • p_display_name: non-null, 1–50 characters.
  • p_bio: null or LENGTH(p_bio) <= 500.
  • p_photo_urls: null or array_length(p_photo_urls, 1) <= 6. If > 6, return { "error": { "code": "VALIDATION", "message": "Maximum 6 photos allowed" } }.
  • p_interest_tags: null or array_length(p_interest_tags, 1) <= 10.

On success: update all supplied fields on the profile row where auth_uid = auth.uid(), set updated_at = now(), return { "data": { "success": true } }.

src/lib/storage.ts

All Storage operations go through this module. Import supabaseClient from src/lib/supabase.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract established in src/lib/profile.ts.

The Storage bucket name is profile-photos. Photo paths follow the pattern {auth_uid}/{uuid}.{extension}, where auth_uid is the current session user's ID and extension is derived from the file's MIME type.

Export the following named functions:

  • uploadPhoto(file: File): Promise<string> — client-side gates (throw before any Storage call if violated):

    • Allowed MIME types: image/jpeg, image/png, image/webp. If the file's type is not one of these, throw ValidationError with code: 'VALIDATION' and message 'Only JPEG, PNG, and WebP images are supported'.
    • Maximum file size: 10 MB (10 * 1024 * 1024 bytes). If exceeded, throw ValidationError with message 'Image must be 10 MB or smaller'.

    After gates pass: generate a UUID for the filename, construct the storage path, upload to the profile-photos bucket using the Supabase Storage SDK. On upload success, retrieve and return the public URL for the uploaded object. On Storage RLS rejection, throw PermissionError. On any other SDK error, throw ServiceError.

  • deletePhoto(publicUrl: string): Promise<void> — derive the storage path from the public URL by extracting the path segment after the bucket name. Delete the object using the Supabase Storage SDK. On SDK error, throw ServiceError. If the object is already gone, treat as success.

src/lib/profile.ts (add one export to the existing module)

Add the following named export without changing any existing exports:

  • updateProfile(params: { displayName: string; bio: string; interestTags: string[]; genderIdentity: string; sexualOrientation: string; photoUrls: string[] }): Promise<void> — calls the update_profile RPC. Maps procedure and SDK errors using the same error mapping contract already in this module. Returns void on success.

src/pages/ProfileEditorPage.tsx (replaces placeholder)

On mount, fetch the current user's profile using getMyProfile() wrapped in a TanStack Query useQuery. While loading, show a skeleton/spinner. On NotFoundError, redirect to /onboarding.

Render two sections:

Photos grid:

  • Up to 6 photo slots displayed in a grid.
  • Occupied slots: show the photo with a remove ("×") button overlaid. Clicking remove calls deletePhoto(url) then updateProfile with the URL removed from the array. Show a per-slot loading state during the operation.
  • Empty slots (up to the 6-photo maximum): show an upload button. Clicking opens a file picker. On file selection, call uploadPhoto(file) then updateProfile with the new URL appended to the existing array. Show a per-slot loading state during the operation.
  • Per-slot errors (from ValidationError or ServiceError) are shown as text below that slot.

Edit fields:

  • Display name (text input, max 50 characters, shows character count).
  • Bio (textarea, max 500 characters, shows character count).
  • Gender identity (select menu with the same options as onboarding).
  • Sexual orientation (select menu with the same options as onboarding).
  • Interest tags (multi-select with the same fixed tags as onboarding, max 10).
  • "Save changes" button — calls updateProfile with all current field values plus the current photo_urls array. Shows loading state during call. On ValidationError, shows the error message inline next to the relevant field. On ServiceError, shows the message at the top of the form with a retry option.

After a successful save, invalidate the getMyProfile query so the UI reflects the updated data.


Constraints:

  • uploadPhoto must validate MIME type and file size client-side before making any Storage SDK call.
  • deletePhoto must derive the Storage path from the public URL — do not store paths separately.
  • updateProfile in src/lib/profile.ts must be an addition to the existing module, not a replacement. All exports from Prompt 4 must remain unchanged.
  • Do not modify any migration files from Prompts 1–4 or any files in src/lib/ other than profile.ts and the new storage.ts.
  • The profile-photos Storage bucket must exist in Supabase before this prompt's code runs. Create it manually in the Supabase dashboard → Storage → New Bucket → name: profile-photos, set to Public.

Check before continuing (do this yourself, do not paste it):

  • Upload a JPEG photo in the profile editor and confirm it appears in the photo grid and in the Supabase Storage browser under profile-photos/{your-uid}/.
  • Upload 6 photos and attempt to upload a 7th — confirm the stored procedure returns a validation error shown in the UI (not a browser crash).
  • Attempt to upload a PDF file — confirm it is rejected client-side before any network request is made.
  • Delete a photo and confirm it is removed from both the UI grid and the Supabase Storage browser.
  • If something is wrong: tell Bolt "The photo upload is failing — log the raw Storage SDK response for debugging without changing the upload logic."

Prompt 6

Run this after Prompt 5 is complete.

This prompt builds the core product loop — the stored procedures that find nearby candidates and record swipes, the swipe deck UI, and the filter preferences screen. When this is done, signed-in users with complete profiles can swipe on real candidate profiles and see a paywall when their daily limit runs out. Match creation also works end-to-end, though the chat UI comes in the next prompt.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–5 are complete: the project skeleton, OAuth, database schema, types, onboarding, and photo/profile editing are all working. Now build the candidate discovery stored procedure, the swipe stored procedure, the filter store, and the discovery and settings UIs.

Do not build: chat, the matches list screen, the Stripe payment flow, or the full paywall modal (create a stub component for the paywall that renders a placeholder message — it will be completed in Prompt 8).


Files to create or replace

supabase/migrations/0008_discovery_proc.sql

Creates discover_candidates(p_lat float8, p_lng float8, p_radius_km float8, p_age_min integer, p_age_max integer, p_gender_prefs text[], p_orientation_filter text, p_interest_tags text[], p_page_offset integer, p_page_size integer DEFAULT 20) as a SECURITY DEFINER function returning jsonb.

Algorithm — each step depends on the previous completing successfully:

Step 1 — Input validation. Validate:

  • p_lat between -90 and 90; p_lng between -180 and 180.
  • p_radius_km between 1 and 200.
  • p_age_min >= 18; p_age_max <= 100; p_age_max >= p_age_min.
  • p_page_size <= 50. On any failure, return { "error": { "code": "VALIDATION", "message": "..." } }.

Step 2 — Quota check. Query swipe_quotas where user_id = auth.uid() AND quota_date = CURRENT_DATE. If a row exists and swipes_used >= quota_limit, return { "data": { "candidates": [], "quota_exhausted": true } }. If no row exists for today, the user has not yet swiped today — treat as not exhausted and continue.

Step 3 — Caller profile lookup. Retrieve caller_gender and caller_orientation from profiles where auth_uid = auth.uid().

Step 4 — Candidate query. Query profiles applying all of the following conditions in a single query:

  • ST_DWithin(location, ST_MakePoint(p_lng, p_lat)::geography, p_radius_km * 1000.0).
  • auth_uid != auth.uid().
  • deleted_at IS NULL AND suspended = false.
  • photo_urls IS NOT NULL AND array_length(photo_urls, 1) > 0.
  • Age computed as DATE_PART('year', AGE(CURRENT_DATE, date_of_birth)) is between p_age_min and p_age_max.
  • If p_gender_prefs is non-null and non-empty: gender_identity = ANY(p_gender_prefs).
  • Orientation compatibility: exclude candidates whose sexual_orientation indicates they are exclusively interested in a gender that does not match caller_gender. Implement this as an inline CASE expression or helper condition. The compatibility rule is: if the candidate's orientation value explicitly excludes the caller's gender (e.g., a candidate who is "Straight" and whose gender_identity is "Woman" would be excluded for a caller whose caller_gender is "Woman"), exclude that candidate. Where orientation is ambiguous or inclusive (e.g., "Bisexual", "Pansexual", "Other"), always include. Use the same gender and orientation string values defined in the onboarding step options.
  • Anti-join against swipes: NOT EXISTS (SELECT 1 FROM swipes WHERE swiper_id = auth.uid() AND target_id = profiles.auth_uid).
  • Anti-join against blocks: NOT EXISTS (SELECT 1 FROM blocks WHERE (blocker_id = auth.uid() AND blocked_id = profiles.auth_uid) OR (blocker_id = profiles.auth_uid AND blocked_id = auth.uid())).
  • If p_interest_tags is non-null and non-empty: profiles.interest_tags && p_interest_tags (array overlap operator).

Step 5 — Ordering. Order by: (boost_expires_at > now()) DESC NULLS LAST, then ST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography) ASC.

Step 6 — Pagination. Apply LIMIT p_page_size OFFSET p_page_offset.

Step 7 — Result construction. For each result row, compute:

  • age: integer years from date_of_birth.
  • distance_label: if ST_Distance(location, ST_MakePoint(p_lng, p_lat)::geography) < 1000, use '< 1 km away'; otherwise use ROUND(ST_Distance(...) / 1000.0)::text || ' km away'.
  • boost_active: boolean, true if boost_expires_at > now().
  • Include auth_uid as profile_id, display_name, interest_tags, photo_urls.
  • Do not include date_of_birth, location, raw coordinates, or any other column not listed above.

Return { "data": { "candidates": [...], "quota_exhausted": false } }.

supabase/migrations/0009_swipe_proc.sql

Creates record_swipe(p_target_id uuid, p_direction text, p_super_like boolean DEFAULT false) returning jsonb as a SECURITY DEFINER function. Runs entirely within a single transaction (BEGIN/COMMIT).

Algorithm:

Step 1 — Validation. Validate p_direction IN ('like', 'pass', 'super_like'). Validate p_target_id != auth.uid(). On failure, return { "error": { "code": "VALIDATION", "message": "..." } }.

Step 2 — Target exists check. Confirm a profiles row exists for p_target_id with deleted_at IS NULL AND suspended = false. If not, return { "error": { "code": "NOT_FOUND", "message": "Profile not found" } }.

Step 3 — Quota lock and initialization. SELECT ... FOR UPDATE the swipe_quotas row for user_id = auth.uid() AND quota_date = CURRENT_DATE. If no row exists, insert one with: quota_limit set to 10 if the caller's subscription_tier = 'free', or 999999 if 'premium' (look up from profiles); swipes_used = 0; reset_at = (CURRENT_DATE + INTERVAL '1 day')::timestamptz. Then re-select with FOR UPDATE.

Step 4 — Quota enforcement. If swipes_used >= quota_limit, return { "data": { "match_created": false, "match_id": null, "quota_exhausted": true } }. Do not insert anything.

Step 5 — Increment quota. Update swipe_quotas set swipes_used = swipes_used + 1 for this row.

Step 6 — Record swipe. Insert into swipes (swiper_id, target_id, direction, super_like, created_at). On uniqueness constraint violation (duplicate key), catch the exception and return { "error": { "code": "DUPLICATE", "message": "Already swiped this profile" } }.

Step 7 — Mutual match check. If p_direction IN ('like', 'super_like'): query swipes for a row where swiper_id = p_target_id AND target_id = auth.uid() AND direction IN ('like', 'super_like'). If found:

  • Insert into matches: participant_a_id = p_target_id (the earlier swiper), participant_b_id = auth.uid().
  • Insert into conversations: match_id = <new match id>.
  • Insert into messages: conversation_id = <new conversation id>, sender_id = auth.uid(), content = 'You matched! Say hello 👋'.
  • Return { "data": { "match_created": true, "match_id": "<new match uuid>", "quota_exhausted": false } }.

Step 8 — No match. Return { "data": { "match_created": false, "match_id": null, "quota_exhausted": false } }.

src/lib/discovery.ts

Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.

Export:

  • discoverCandidates(params: { lat: number; lng: number; radiusKm: number; ageMin: number; ageMax: number; genderPrefs: string[]; orientationFilter: string; interestTags: string[]; pageOffset: number }): Promise<DiscoveryResult> — calls the discover_candidates RPC. On procedure error, maps to AppError subclass. On success, returns DiscoveryResult.
  • recordSwipe(params: { targetId: string; direction: SwipeDirection; superLike?: boolean }): Promise<SwipeResult> — calls the record_swipe RPC. On ProcedureError.code === 'DUPLICATE', throws DuplicateActionError. On success (including quota_exhausted: true in the data), returns SwipeResult — the quota exhausted case is not an error, it is a valid result the calling component handles by showing the paywall.

src/lib/filters.ts

Manages discovery filter preferences stored in localStorage.

Export:

export interface FilterPreferences {
  radiusKm: number;
  ageMin: number;
  ageMax: number;
  genderPrefs: string[];
  orientationFilter: string;
  interestTags: string[];
}
  • getFilterPreferences(userId: string): FilterPreferences — reads from localStorage key aura_filters_{userId}. Returns defaults { radiusKm: 50, ageMin: 18, ageMax: 99, genderPrefs: [], orientationFilter: '', interestTags: [] } if no stored value is found or parsing fails.
  • setFilterPreferences(userId: string, prefs: FilterPreferences): void — serializes and writes to localStorage key aura_filters_{userId}.

src/components/PaywallModal.tsx

Stub only at this phase. Renders a placeholder message: "Premium required — payment coming soon." Accepts onDismiss: () => void as a prop. The dismiss button calls onDismiss. This stub will be replaced in Prompt 8.

src/pages/DiscoveryPage.tsx (replaces placeholder)

On mount:

  1. 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.
  2. Read filter preferences using getFilterPreferences(session.user.id).
  3. Call discoverCandidates using the current coordinates and filter preferences with pageOffset: 0.

Deck behavior:

  • Render a stack of candidate cards showing: first photo, display name, age, distance label, interest tags.
  • Swipe right (or tap a "Like" button) calls recordSwipe with direction: 'like'.
  • Swipe left (or tap a "Pass" button) calls recordSwipe with direction: 'pass'.
  • After each swipe, remove the card from the local deck. When the unviewed card count drops to 3, fetch the next page (pageOffset incremented by the page size) and append results to the deck.
  • On DuplicateActionError, treat as a no-op (the card is removed without showing an error).
  • If SwipeResult.match_created is true, show an in-app modal: "It's a Match! 🎉" with a "Start chatting" button that navigates to /chat/{match_id} and a "Keep swiping" button that dismisses the modal.
  • If SwipeResult.quota_exhausted is true, render <PaywallModal onDismiss={() => {}} />.
  • If DiscoveryResult.quota_exhausted is true on the initial fetch, render <PaywallModal onDismiss={() => {}} /> immediately.
  • Empty state (no candidates, quota not exhausted): "No one nearby — try expanding your distance or filters."

src/pages/SettingsPage.tsx (replaces placeholder)

Read filter preferences via getFilterPreferences(session.user.id). Render controls:

  • Distance slider: 1–200 km, current value shown as label.
  • Age range: two number inputs or a dual-handle slider, min 18, max 99.
  • Gender preference: checkboxes for "Man", "Woman", "Non-binary", "Other". Multiple selectable.
  • Interest tags: same multi-select as onboarding, max 10.
  • Every change immediately calls setFilterPreferences — no save button needed.

Constraints:

  • discover_candidates must never return date_of_birth, location, or raw coordinate values in the result.
  • record_swipe must run steps 3–8 within a single transaction.
  • recordSwipe in src/lib/discovery.ts must return SwipeResult (not throw) when quota_exhausted: true is in the result data — the component, not the library, decides to show the paywall.
  • Do not modify any migration files from Prompts 1–5, or any file in src/lib/ other than discovery.ts and the new filters.ts.
  • Do not build the full Stripe paywall — PaywallModal is a stub.

Check before continuing (do this yourself, do not paste it):

  • With two test accounts that have both completed onboarding and uploaded at least one photo, confirm that Account A sees Account B as a candidate (and vice versa) when they are within the configured radius.
  • Swipe right as Account A on Account B, then right as Account B on Account A — confirm exactly one match row and one conversation row appear in the Supabase Table Editor.
  • As a free-tier user, perform 10 swipes and confirm the 11th swipe returns quota_exhausted: true and the stub paywall modal appears (no additional swipe row is created).
  • Confirm raw coordinates do not appear anywhere in the candidate cards or browser console from the discovery RPC response.
  • If something is wrong: tell Bolt "The discovery or swipe RPC is returning an unexpected result — log the raw RPC response to the console for debugging without changing the procedure logic."

Prompt 7

Run this after Prompt 6 is complete.

This prompt builds the live social experience — the matches list, real-time chat, match notifications, and the unmatch and block actions. When this is done, matched users can send messages to each other that appear instantly in both browser windows, and either user can unmatch or block at any time.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–6 are complete: skeleton, OAuth, schema, types, onboarding, photos, discovery, and swipe engine are all working. Now build the matches list, chat screen, Realtime subscriptions, and block/unmatch flows.

Do not build: Stripe integration, account deletion, or security headers.


Files to create or replace

supabase/migrations/0010_block_unmatch_procs.sql

Creates two SECURITY DEFINER stored procedures.

block_user(p_blocked_id uuid) returning jsonb. Runs in a single transaction:

  1. Validate p_blocked_id != auth.uid(). On failure, return { "error": { "code": "VALIDATION", "message": "Cannot block yourself" } }.
  2. Insert into blocks (blocker_id, blocked_id). On uniqueness constraint violation, catch and continue (already blocked — treat as no-op).
  3. Query matches for any row where (participant_a_id = auth.uid() AND participant_b_id = p_blocked_id) OR (participant_a_id = p_blocked_id AND participant_b_id = auth.uid()) and active = true. If found: update matches.active = false, matches.deleted_at = now(); update conversations.deleted_at = now() for the linked match_id.
  4. Return { "data": { "success": true } }.

unmatch(p_match_id uuid) returning jsonb. Runs in a single transaction:

  1. Verify a match row exists for p_match_id where auth.uid() IN (participant_a_id, participant_b_id). If not found or caller is not a participant, return { "error": { "code": "NOT_FOUND", "message": "Match not found" } }.
  2. Update matches.active = false, matches.deleted_at = now() for id = p_match_id.
  3. Update conversations.deleted_at = now() where match_id = p_match_id.
  4. Return { "data": { "success": true } }.

src/lib/matches.ts

Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.

Export:

  • getMatches(): Promise<Match[]> — selects all rows from matches where auth.uid() IN (participant_a_id, participant_b_id) and active = true and deleted_at IS NULL, ordered by created_at DESC. Returns Match[].
  • unmatch(matchId: string): Promise<void> — calls the unmatch RPC. Maps errors per contract.
  • blockUser(blockedId: string): Promise<void> — calls the block_user RPC. Maps errors per contract.

src/lib/messages.ts

Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.

Export:

  • getMessages(conversationId: string): Promise<Message[]> — selects all messages rows where conversation_id = conversationId and deleted = false, ordered by sent_at ASC. Throws PermissionError if RLS rejects. Returns Message[].
  • sendMessage(conversationId: string, content: string): Promise<Message> — validates: content.trim() is non-empty and content.length <= 2000 (client-side gate; throw ValidationError with message 'Message cannot be empty' or 'Message must be 2000 characters or fewer'). Inserts a row into messages with conversation_id, sender_id = auth.uid(), content = content.trim(). On RLS rejection, throws PermissionError with message 'Message not delivered — this match may no longer be active'. On other SDK error, throws ServiceError. Returns the inserted row as Message.
  • getConversationIdForMatch(matchId: string): Promise<string> — selects id from conversations where match_id = matchId and deleted_at IS NULL. Throws NotFoundError if no row is returned. Returns the conversation id string.
  • subscribeToMessages(conversationId: string, onMessage: (message: Message) => void): () => void — subscribes to Supabase Realtime Postgres Changes INSERT events on the messages table, filtered to conversation_id = conversationId. The subscription's error/reconnect handler must call getMessages(conversationId) to re-fetch from the last known sent_at and deliver any missed messages through onMessage before re-establishing the subscription. Returns an unsubscribe function that removes the channel.
  • subscribeToMatchNotifications(userId: string, onMatch: (match: Match) => void): () => void — subscribes to Supabase Realtime Postgres Changes INSERT events on the matches table, filtered so that new rows where participant_a_id = userId OR participant_b_id = userId trigger onMatch. Because Supabase Realtime filter expressions support a single equality condition per subscription, implement this as two separate channel subscriptions (one for participant_a_id, one for participant_b_id) and call onMatch from either. Return an unsubscribe function that removes both channels.

src/lib/reports.ts

Import supabaseClient from src/lib/supabase.ts. Import types from src/lib/types.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.

Export:

  • reportEntity(params: { entityType: 'profile' | 'message'; entityId: string; reason: ReportReason; detail?: string }): Promise<void> — inserts a row into reports with reporter_id = auth.uid(), entity_type, entity_id, reason, detail. On RLS rejection, throws PermissionError. On other SDK error, throws ServiceError.

src/pages/MatchListPage.tsx (replaces placeholder)

Fetches matches via getMatches() using TanStack Query with refetchInterval: 30000. While loading, shows a spinner. Empty state: "No matches yet — keep swiping!"

For each match, display a card showing the other participant's display_name and first photo_url. To get the other participant's profile data, fetch it via a getMyProfile-style query scoped to the other participant's auth_uid (select from profiles where auth_uid matches whichever of participant_a_id / participant_b_id is not the current user). Tapping a match card navigates to /chat/{match.id}.

On mount, call subscribeToMatchNotifications(session.user.id, ...). When a new match arrives:

  • Invalidate the getMatches TanStack Query.
  • Show a toast notification: "It's a Match! 🎉"

Unsubscribe from the Realtime channel on unmount.

src/pages/ChatPage.tsx (replaces placeholder)

The route provides matchId via useParams().

On mount:

  1. Call getConversationIdForMatch(matchId). On NotFoundError, show "Conversation not found" and a back button to /matches.
  2. Call getMessages(conversationId) to load history via TanStack Query.
  3. Call subscribeToMessages(conversationId, ...). On each incoming message, append to the local message list. Track the subscription error state; when an error occurs, show a "Reconnecting…" banner.

Message list: renders each message, aligning the current user's messages to the right and the other participant's to the left. Scroll to the bottom on mount and on each new message.

Input bar at the bottom: text input and a "Send" button. On send, call sendMessage(conversationId, content). On ValidationError, show the error inline. On PermissionError, show "Message not delivered — this match may no longer be active." Clear the input on successful send.

Overflow menu (three-dot or similar) in the header containing:

  • Unmatch: shows a confirmation dialog "Are you sure? This will end your match." Confirming calls unmatch(matchId) then navigates to /matches. On error, shows the error message.
  • Block: shows a confirmation dialog "Block this user? They will no longer appear in your matches or discovery." Confirming calls blockUser(otherParticipantId) then navigates to /matches. On error, shows the error message.
  • Report: opens a modal with a reason selector (options: "Spam", "Harassment", "Underage", "Inappropriate", "Other") and an optional detail text input. Submitting calls reportEntity with entityType: 'profile' and the other participant's ID. Shows a success confirmation or error inline.

Unsubscribe from the messages Realtime channel on unmount.


Constraints:

  • subscribeToMessages must handle the reconnection gap-fill: on reconnect, re-fetch messages since the last known sent_at and deliver them through onMessage before re-subscribing.
  • subscribeToMatchNotifications must use two separate Realtime channel subscriptions to cover both participant columns.
  • sendMessage must never store raw content in component state before trimming.
  • Do not modify any migration files from Prompts 1–6, or any files in src/lib/ other than the new matches.ts, messages.ts, and reports.ts.
  • Do not build Stripe, account deletion, or security headers.

Check before continuing (do this yourself, do not paste it):

  • Open two browser windows signed in as two mutually matched users. Send a message as User A and confirm it appears in User B's chat window within 2 seconds without a page refresh.
  • Unmatch as User A and confirm both users' match lists no longer show the match, and neither can open the conversation.
  • Block User B as User A — confirm User B no longer appears in User A's discovery results (run a new discovery query and check).
  • Submit a report and confirm a row appears in the reports table in the Supabase dashboard with review_status = 'pending'.
  • If something is wrong: tell Bolt "The Realtime subscription is not delivering messages — log the channel status and any errors without changing the subscription logic."

Prompt 8

Run this after Prompt 7 is complete.

This prompt wires up Stripe payments — the Edge Function that creates a checkout session, the webhook that upgrades a user's account after payment, and the real paywall modal that replaces the stub from Prompt 6. When this is done, a free-tier user who hits their swipe limit can pay to upgrade and immediately get unlimited swipes.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–7 are complete: the full app is working including discovery, swiping, matching, and chat. Now build Stripe Checkout integration: the Edge Functions that create checkout sessions and handle payment webhooks, and the real paywall modal.

Do not build: account deletion, security headers, or any other feature.


Files to create or replace

supabase/functions/create-checkout-session/index.ts

Supabase Edge Function (Deno runtime). Handles authenticated POST requests from the client.

Processing sequence:

  1. Parse the request's Authorization header to extract the bearer token. Use SUPABASE_SERVICE_ROLE_KEY (from environment) to initialize a Supabase admin client and verify the JWT. If invalid or missing, return a JSON response with HTTP 401: { "error": { "code": "PERMISSION", "message": "Unauthorized" } }.
  2. Extract the user's auth_uid from the verified JWT claims.
  3. Using STRIPE_SECRET_KEY (from environment), create a Stripe Checkout session with:
    • mode: 'subscription'
    • A line item referencing the Aura Premium price. The price ID must be read from an environment variable named STRIPE_PREMIUM_PRICE_ID — do not hardcode it.
    • client_reference_id set to the user's auth_uid.
    • success_url read from an environment variable named APP_URL, with ?session=success appended.
    • cancel_url read from APP_URL with ?session=cancelled appended.
  4. Return { "data": { "url": "<checkout session url>" } } with HTTP 200.
  5. 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:

  1. Read the raw request body as text and read the Stripe-Signature header.
  2. 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.
  3. Parse the verified event JSON.
  4. If the event type is not checkout.session.completed, return HTTP 200 immediately with { "received": true }.
  5. Extract client_reference_id from the session object — this is the user's auth_uid.
  6. Using SUPABASE_SERVICE_ROLE_KEY, initialize a Supabase admin client. Update the profiles row where auth_uid = client_reference_id: set subscription_tier = 'premium' and updated_at = now().
  7. Upsert a swipe_quotas row for user_id = client_reference_id and quota_date = CURRENT_DATE: set quota_limit = 999999. If a row already exists, update only quota_limit.
  8. Return HTTP 200 with { "received": true }.
  9. On any unhandled exception: log the error to standard output (visible in Supabase Edge Function logs) and return HTTP 500 so Stripe retries.

This function is idempotent: processing the same checkout.session.completed event twice sets the same values twice, with no side effects.

src/lib/stripe.ts

Import supabaseClient from src/lib/supabase.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.

Export:

  • createCheckoutSession(): Promise<string> — retrieves the current session JWT using supabaseClient.auth.getSession(). Calls the create-checkout-session Edge Function via supabaseClient.functions.invoke(...), passing the JWT in the Authorization header. On success, returns the URL string from the response's data.url field. On any error (function invocation failure, non-2xx response, or missing URL in response), throws ServiceError with message 'Payment service unavailable — try again later'.

src/components/PaywallModal.tsx (replaces Prompt 6 stub)

Props: onDismiss: () => void.

Renders:

  • A heading: "You've used all your free swipes today"
  • Body text: "Upgrade to Premium for unlimited swipes, super likes, and more."
  • The free tier daily limit: "Free plan: 10 swipes per day"
  • An "Upgrade to Premium" button. On click: call createCheckoutSession(). While the call is in progress, show a loading spinner on the button and disable it. On success, redirect the browser to the returned URL using window.location.href. On ServiceError, show the error message below the button.
  • A "Maybe later" link that calls onDismiss.

Constraints:

  • The STRIPE_PREMIUM_PRICE_ID and APP_URL values must come from Supabase Edge Function environment secrets — never hardcoded.
  • The stripe-webhook function must not require a Supabase JWT — it is called by Stripe, not the client.
  • The create-checkout-session function must validate the JWT before doing anything else.
  • createCheckoutSession() in src/lib/stripe.ts must pass the JWT to the Edge Function — the function cannot call auth.uid() without it.
  • Do not modify any migration files, or any files in src/lib/ other than the new stripe.ts.

Check before continuing (do this yourself, do not paste it):

  • As a free-tier user, exhaust your daily swipes and confirm the real paywall modal appears (not the old stub) with the "Upgrade to Premium" button.
  • Click "Upgrade to Premium" and confirm you are redirected to a Stripe-hosted checkout page.
  • Complete a test payment using Stripe's test card number. In the Supabase dashboard → Table Editor → profiles, confirm subscription_tier changed to 'premium' for your test user.
  • In Stripe Dashboard → Developers → Webhooks, confirm the webhook event was delivered with a 200 response.
  • If something is wrong: tell Bolt "The webhook is not updating the profile — log the full event payload and Supabase update response inside the Edge Function without changing the webhook logic."

Setup step — do this now, before testing: In Stripe Dashboard → Developers → Webhooks, add your Supabase Edge Function URL as a webhook endpoint. The URL is in your Supabase project → Edge Functions → stripe-webhook → URL. Set it to listen for the checkout.session.completed event. Copy the signing secret it generates and add it as STRIPE_WEBHOOK_SIGNING_SECRET in Supabase project secrets. Also add STRIPE_PREMIUM_PRICE_ID (from your Stripe product's price ID) and APP_URL (your Vercel production domain) to Supabase secrets.


Prompt 9

Run this after Prompt 8 is complete.

This prompt builds the account deletion flow — the Edge Function that permanently erases a user's personal data in compliance with GDPR, and the UI in the Settings screen that lets users request deletion. When this is done, a user can delete their account and all their personal data is removed from the system, while message records are retained (anonymized) for moderation purposes.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–8 are complete: the full app including payments is working. Now build the GDPR account deletion Edge Function and the delete account UI in Settings.

Do not build: security headers, error boundaries, or any other feature.


Files to create or replace

supabase/functions/gdpr-erase/index.ts

Supabase Edge Function (Deno runtime). Handles authenticated DELETE requests from the client.

Processing sequence — each step is designed to be safely re-runnable on retry (idempotent):

Step 1 — Auth validation. Parse the Authorization header. Using SUPABASE_SERVICE_ROLE_KEY, verify the JWT and extract user_id. If invalid or missing, return HTTP 401.

Step 2 — Anonymize profile PII. Using the admin client, update the profiles row where auth_uid = user_id: set display_name = '[deleted]', date_of_birth = null, gender_identity = null, sexual_orientation = null, interest_tags = null, bio = null, location = null, photo_urls = null, deleted_at = now(). If the row is already anonymized (fields already null), the update is a no-op — treat as success.

Step 3 — Delete Storage photos. List all objects in the profile-photos bucket under path {user_id}/. For each object found, delete it. If the folder does not exist or is empty, proceed without error.

Step 4 — Anonymize message sender IDs. Using the admin client, update all rows in messages where sender_id = user_id, setting sender_id = '00000000-0000-0000-0000-000000000000'. This UUID does not reference any real profile row. On retry, the WHERE clause sender_id = user_id will match zero rows (already anonymized), so no rows are updated — this is correct behavior.

Step 5 — Delete Auth user. Call the Supabase Admin API to delete the user record for user_id. If the response indicates the user does not exist (already deleted — retry scenario), treat as success.

Step 6 — Return. On completing all steps without exception, return HTTP 200 with { "data": { "success": true } }. If any step throws, log the step name and error to standard output, then return HTTP 500 with { "error": { "code": "INTERNAL", "message": "Erasure incomplete — retry to continue" } }.

src/lib/account.ts

Import supabaseClient from src/lib/supabase.ts. Import signOut from src/lib/auth.ts. Import error classes from src/lib/errors.ts. Apply the same error mapping contract from src/lib/profile.ts.

Export:

  • deleteAccount(): Promise<void> — retrieves the current JWT from supabaseClient.auth.getSession(). Invokes the gdpr-erase Edge Function via supabaseClient.functions.invoke(...) with a DELETE method, passing the JWT in the Authorization header. On HTTP 200 response, calls signOut() and returns. On any error (invocation failure, non-200 response, or missing success field), throws ServiceError with the message from the response body if available, otherwise 'Account deletion failed — please try again'.

src/pages/SettingsPage.tsx (addition to Prompt 6's version)

Add a "Delete Account" section at the bottom of the existing settings content. Do not remove or replace any existing settings content.

Render:

  • A heading: "Delete Account"
  • Body text: "Permanently delete your account and all your personal data. This cannot be undone."
  • A "Delete My Account" button styled to indicate a destructive action.

On click, show a confirmation dialog (modal or browser confirm — your choice) with the message: "This will permanently delete your account and all your data. This cannot be undone." Two options: "Cancel" (dismisses dialog, no action) and "Delete permanently" (confirms).

On confirmation: call deleteAccount(). While the call is in progress, show a loading state on the button and disable it. On success, the function already handles sign-out and the router redirects to /login. On ServiceError, show the error message and a "Try again" button.


Constraints:

  • The gdpr-erase function must validate the JWT before performing any data operation.
  • Steps 2–5 must execute in the order specified — profile anonymization before Storage deletion before message anonymization before Auth user deletion.
  • The anonymized sender_id UUID '00000000-0000-0000-0000-000000000000' must be used exactly as written — this is the canonical anonymization sentinel value for the system.
  • deleteAccount() must call signOut() only after receiving a successful response — not before.
  • Do not modify src/pages/SettingsPage.tsx in any way that removes or changes existing filter preferences content.
  • Do not modify any migration files or any src/lib/ files other than the new account.ts.

Check before continuing (do this yourself, do not paste it):

  • Complete the account deletion flow for a test user and confirm: their profiles row has display_name = '[deleted]' and all PII fields are null; their Storage folder under profile-photos/{uid}/ is empty; their messages show sender_id = '00000000-0000-0000-0000-000000000000'; and their Supabase Auth user record is gone (Authentication → Users in the dashboard).
  • Confirm that after deletion, signing in again with the same Google or Facebook account creates a new user with a fresh auth_uid (a new profile row) rather than restoring the deleted one.
  • Retry the deletion Edge Function for an already-deleted user ID and confirm it returns HTTP 200 (idempotent).
  • If something is wrong: tell Bolt "The gdpr-erase function is failing at step [N] — log the step output and error without changing the erasure sequence."

Prompt 10

Run this after Prompt 9 is complete.

This is the final prompt. It adds the security headers Vercel needs to serve the app safely, wraps every screen in an error boundary so a crash in one part of the app doesn't take down the rest, and provides the RLS audit script you'll run manually before going live. When this is done, the app is ready for production launch.


Paste everything below into your AI coding tool:

You are building Aura, a Tinder-style social matching app. Prompts 1–9 are complete: the full app including payments and account deletion is working. Now add security headers, React error boundaries, and the RLS audit script. This is the final prompt.

Do not build: any new features or screens.


Files to create or replace

vercel.json

Configure Vercel deployment. Apply all headers to every route using the glob pattern that matches all paths.

Security headers — use these exact values:

  • Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://js.stripe.com; img-src 'self' https://*.supabase.co data:; frame-src https://js.stripe.com; style-src 'self' 'unsafe-inline'
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: camera=(), microphone=(), geolocation=(self)

SPA fallback: configure a rewrite rule so that all non-asset requests (requests that do not resolve to a file with an extension) are served index.html with HTTP 200. This is required for React Router client-side routing to work correctly on Vercel.

src/components/ErrorBoundary.tsx

React class component error boundary.

Props:

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;
}

State: { hasError: boolean }. Initially { hasError: false }.

Behavior:

  • componentDidCatch: call captureError(error, { componentStack: info.componentStack ?? '' }) from src/lib/sentry.ts.
  • getDerivedStateFromError: return { hasError: true }.
  • When hasError is true: render fallback if provided; otherwise render a centered message "Something went wrong — reload the page" with a button labeled "Reload" that calls window.location.reload().
  • When hasError is false: render children.

Export ErrorBoundary as a named export.

src/App.tsx (addition)

Import ErrorBoundary from src/components/ErrorBoundary.tsx. Wrap each route's page component in <ErrorBoundary> so that a runtime error in one screen does not crash the router or any other screen.

The wrapping must be applied at the individual route level — not as a single wrapper around the entire router — so that navigating away from an errored screen clears the error boundary state.

Do not change the route definitions, path strings, or the ProtectedRoute structure from Prompt 2.

supabase/migrations/0011_rls_audit.sql

A SQL script for manual verification only — not a migration to be applied via supabase db push.

Begin the file with this comment block:

-- RLS audit script: run manually in the Supabase SQL editor.
-- Do NOT apply via supabase db push.
-- Purpose: verify that RLS is enabled and at least one policy exists on every application table.

The script should query pg_tables and pg_policies (or the Supabase-specific equivalents) to produce a result set showing, for each of the following tables, whether rowsecurity is enabled and how many policies exist: profiles, swipe_quotas, swipes, matches, conversations, messages, blocks, reports.

The output should make it immediately obvious if any table has RLS disabled or zero policies.


Constraints:

  • The Content-Security-Policy value must match the string above exactly — do not add, remove, or reorder directives.
  • ErrorBoundary must be a class component — React error boundaries cannot be function components.
  • ErrorBoundary must call captureError from src/lib/sentry.ts — do not call the Sentry SDK directly from this component.
  • Do not add vercel.json to .gitignore — it must be committed to the repository.
  • 0011_rls_audit.sql must not be applied as a migration — it is a read-only diagnostic script.
  • Do not modify any existing src/lib/ files, migration files from Prompts 1–9, or any page components.

Check before continuing (do this yourself, do not paste it):

  • Deploy to Vercel (push to main) and open the production URL. In your browser's developer tools → Network tab, click any request to the Aura domain and confirm the response headers include X-Frame-Options: DENY and X-Content-Type-Options: nosniff.
  • Navigate directly to /matches in the browser address bar (not via a link) and confirm the page loads correctly — this verifies the SPA fallback rewrite is working.
  • Run 0011_rls_audit.sql in the Supabase SQL editor and confirm every table shows RLS enabled and at least one policy.
  • If something is wrong with security headers: tell Bolt "The Vercel deployment is not returning the security headers — check vercel.json without changing any other files."
  • If the SPA fallback is not working: tell Bolt "Direct navigation to /matches returns a 404 — fix the rewrite rule in vercel.json only."

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:

  1. Prompt 1 — Project skeleton: Supabase client, Sentry error tracking, routing, and placeholder screens
  2. Prompt 2 — Google and Facebook OAuth, session management, and the database trigger that creates a user profile on first sign-in
  3. Prompt 3 — All database tables, indexes, and Row Level Security policies
  4. Prompt 4 — Onboarding flow, age gate, and profile creation stored procedure
  5. Prompt 5 — Photo upload and profile editor
  6. Prompt 6 — Candidate discovery, swipe engine, and filter preferences
  7. Prompt 7 — Matches list, live chat, and block/unmatch flows
  8. Prompt 8 — Stripe payment integration and premium paywall
  9. Prompt 9 — GDPR account deletion flow
  10. 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:5173 for dev, your Vercel production domain for prod
  • Add Redirect URLs: In the same URL Configuration panel, add http://localhost:5173/** for dev and https://your-vercel-domain.vercel.app/** for prod
  • Enable Realtime on two tables: Database → Replication → toggle on for both messages and matches

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