Product Summary
galpal is a women-only friendship PWA for Bangalore. Members finish a hand-reviewed verification call before they can see matches, then meet each other by sending a "wave" with a short reason. Mutual waves open a 1:1 chat. The product is a matches-and-chats surface — nothing more.
Target Persona
On the app to date. Looking for a meditation cohort, a mom-group, or a professional network.
One or two real friends this year. A friendship surface that takes her time seriously.
Safety. Every new platform is an unknown risk. She's been burned by spaces that say "women-only" but don't enforce it.
Genuine, verified safety. A human called her before she could enter. Every match is real. Every profile was reviewed.
What This Is Not
No hearts, no swipes, no streaks-as-shame, no infinite scroll. No Stories strip. No Circles. No group chat. No broadcast. Games and Events exist in the DB but are scheduled for removal and carry no new brand work.
User Journey (End-to-End)
Landing Page ( / )
Anonymous view. Wordmark nav, one-sentence hero, three-step "how it works" row, full-bleed Safety Promise band (women only · real call real human · no hearts), pull-quote, FAQ, charcoal CTA band. Primary CTA: "join galpal" → /login.
Login ( /login )
Single Google OAuth button. On success, Supabase creates auth.users row → handle_new_user() trigger → matching row in public.users. Redirect to /auth/callback → /onboarding or /home.
Onboarding (3 Steps, stacked-deck UI)
Step 1: name, DOB (18+), city tenure, neighborhood, home state. Step 2: work situation, looking_for (≥1), personality type. Step 3: interests (≥3), surprise fact (10–150 chars). Submission of step 3 sets onboarding_completed = true.
Unverified Home
Until admin verifies: welcome banner + blurred 2×2 top-4 matches teaser. Cannot open a match, send a wave, or open a chat. API endpoints gate on verification.
Admin Verification Call
Allowlisted admin opens /admin, reviews profile at /admin/[id], calls the phone number via click-to-call, clicks Accept or Reject. Reject may include internal 500-char note. User can request another call via /profile → POST /api/me/reschedule.
Celebration ( /celebration )
One-time hand-off animation on first post-verification landing. Sets celebration_seen = true, then redirects to /home.
Verified Home
Time-of-day greeting, contextual wave-back card (most recent incoming wave), first-week nudges (Days 1–7, Day 4 skipped if no incoming waves), horizontal scroll of top 3 matches.
Wave Flow
Tapping match card opens ProfileOverlay. Wave button opens modal — 3 preset reasons + custom 100-char textarea. Sending inserts into waves table with expires_at = now() + 30 days. If mutual: creates connections row + fires two push notifications + MATCHED sticker.
1:1 Chat ( /chat/[connectionId] )
Text + image bubbles only. Realtime subscription on mount, removed on unmount. Three-dot menu: Disconnect / Block / Block + Report.
Feature Specifications
Authentication — Google OAuth
Single /login page. Client invokes supabase.auth.signInWithOAuth with Google provider. Middleware gates everything except /, /login, /privacy, /terms, /auth/callback. /admin/* allowed through middleware — auth handled at API layer.
Verification Workflow
User-Side Signals
Unverified home banner → "our team will reach out within 24 hours." If missed call: /profile → "Request another call" → POST /api/me/reschedule. Verification card flips butter-pending → mint-verified on acceptance.
Admin-Side Workflow
/admin lists: reschedule requests first, then unverified newest-first, then verified. Accept/Reject actions via PATCH /api/admin. Reject opens 500-char note sheet. Reset clears all verification columns.
Wave Rules
| Rule | Detail |
|---|---|
| Expiry | 30 days from created_at. Expired waves still count toward the lifetime cap. |
| Lifetime cap | Maximum 3 total waves from the same sender to the same recipient — ever. Button disabled at 3, showing "you've waved 3 times." |
| Rate limit | 10 waves per hour per user via Upstash Redis. Returns 429 with Retry-After header. |
| Cannot wave | Yourself, someone you're already connected to, same recipient while a previous wave is still active. |
| Mutual detection | On every wave insert, server checks for any earlier wave from recipient to sender (active OR expired). If present: service-role creates connections, fires 2 push notifications + 2 wave_matched notifications. |
Wave States Surfaced to Client
1:1 Chat
Message types: text | image | system. GET /api/messages paginates backwards from created_at via cursor, 50 messages per page. Membership checked server-side (defense in depth — RLS exists but not solely relied upon). Rate limit: 60 messages per minute.
Safety Actions
Disconnect
Sets connections.status = 'disconnected'. Inserts "Conversation ended" system message. Soft, reversible from DB layer — no reconnect UI today.
Block (symmetric, silent)
Sets connections.status = 'blocked'. Inserts blocks row. Applied via src/lib/blocks.ts to matches, user-matches, search, and profile-detail endpoints. Blocked user sees "user not found."
Report
Writes to reports table. Rate-limited: 5 per day. No internal review surface yet — v3 scope confirmed.
Verification Gate
Unverified users cannot see match details, send waves, or open chats. Blurred match teaser is the only preview. Enforced at API layer.
Matching System
Match Sources
Weekly Cron
POST /api/cron/weekly-matches (protected by CRON_SECRET). Inserts up to 10 rows per verified user into user_matches with source = 'weekly'. Fires push notification to each affected user.
On-Demand
POST /api/matches/generate. Inserts up to 10 rows with source = 'on_demand', excluding existing matches, connections, and blocks.
Live Fallback
GET /api/matches. Called only when user_matches has no rows. Never writes to DB.
User-Facing
GET /api/user-matches?limit&offset. Reads user_matches, enriches with profiles, interests, wave status, common line. Filters blocks (symmetric) and existing connections.
Scoring Features
| Feature | Weight |
|---|---|
| Shared interests | +3 per shared interest |
| Shared "looking for" tags | +4 per shared tag |
| Same home state | +2 |
| Neighborhood proximity (same) | +4 or +5 |
| Neighborhood cluster | +2 or +3 |
| Same city tenure | +1 |
| Same work situation | +2 |
| Personality compatibility | 0–3 via lookup table |
| Recency boost (live path only) | Decays +3 → 0 over 30 days |
| Diverse pick: same home_state penalty | −5 if already picked |
| Diverse pick: same neighborhood penalty | −3 if already picked |
Platform & Tech Stack
Brand Rules with API-Level Consequences
Core Data Model
Key Tables
| Table | Key Columns / Notes |
|---|---|
| users | Full profile: neighborhood, city_tenure, home_state, work_situation, looking_for[], personality_type, surprise_fact, is_verified, verified_at, celebration_seen, nudges_dismissed (JSONB), reschedule_count, rejected_at, rejection_reason |
| user_interests | Many-to-many: user_id + interest tag. Replaced on each onboarding step 3 save. |
| waves | from_user, to_user, reason_type, custom_reason (nullable), expires_at (now() + 30 days). RLS: select where from_user = auth.uid() OR to_user = auth.uid() |
| connections | user_a (LEAST of sender/recipient), user_b, status (active | disconnected | blocked), connected_via ('wave' | 'event'). Deterministic ordering preserves unique index. |
| messages | connection_id, sender_id, content, type (text | image | system), image_url. Legacy enum values voice|poll still exist in DB but no new writes. |
| notifications | type: wave_received | wave_matched | wave_back_reminder | match_available | verification_approved. Realtime-published per user channel. |
| blocks | blocker_id, blocked_id. Applied symmetrically in src/lib/blocks.ts across all user-facing queries. |
| user_matches | Persisted scored matches. source: 'weekly' | 'on_demand'. Enriched with wave_status at read time. |
| waitlist | email, created_at. Written to from landing page for pre-signup users. |
RLS Policies (Representative)
users — self-read, self-update, public read of public fields
waves — select where from_user = auth.uid() OR to_user = auth.uid()
connections — select where user_a = auth.uid() OR user_b = auth.uid()
messages — select if a member of the connection, insert where sender_id = auth.uid()
blocks — select + all operations where blocker_id = auth.uid()
Known Limitations & Tech Debt
Streak incrementer has a short-circuit bug
In src/app/api/messages/route.ts, once a streak reaches 1, the || current_streak > 0 branch keeps the streak alive regardless of gap. Streaks effectively never break. Do NOT surface streaks in marketing until this is fixed.
Chat images upload to the avatars bucket
Intentional shortcut. Move to dedicated chat-images bucket before scaling storage.
Rate limits are optional
When UPSTASH_REDIS_REST_URL / _TOKEN are missing, all limiters silently bypass. In production these env vars MUST be set or wave/message/report endpoints become unlimited.
Auth claim in CLAUDE.md is stale
Still says "Phone OTP only via Supabase Auth + Twilio." Actual: Google OAuth. Update on next edit pass.
Legacy schema tables still exist
circles, circle_members, stories, story_responses, weekend_plan_joins, user_prompts. No UI writes to them. Drop on a future migration after product signs off.
Hard-coded admin emails fallback
src/app/api/admin/route.ts and /admin/users/[id]/route.ts contain 3 hard-coded email addresses as fallback. Move to env before public launch.
sessionStorage key queens_matches leaks old name
Rename to galpal_matches as part of the rebrand sweep.
Legacy brand tokens still in code
font-display (Caveat), font-nunito, font-mono, terracotta/sage/butter raw hex colors, bg-cream. Rebrand migration is in-progress and not yet complete in code.
Open Questions
| OQ | Question | Status |
|---|---|---|
| 1 | Events + Games removal cutover date? Keep DB tables for history or drop with UI? | Open |
| 2 | Streak rule — what breaks a streak? Proposed: "both users send at least one message on each of N consecutive local days." | Open |
| 3 | Domain confirmed as galpal.in. VAPID, Supabase redirect URLs, Google OAuth, email sender, and footer copy have been aligned to the new domain. | Resolved |
| 4 | RLS audit — who owns it and when? | Open |
| 5 | Reschedule cap — do we cap reschedule_count? Current: unlimited. | Open |
| 6 | Wordmark SVG with linked-arms ligature — ETA from design team? | Open |
| 7 | 4 bespoke illustrations — Empty Friends (teacups + envelope), Home unverified (door ajar), Onboarding complete (linked hands). Blocks onboarding complete screen. | Open |