Files
Sauci/docs/match-notifications.md
2025-12-29 23:10:12 +00:00

294 lines
10 KiB
Markdown

# Match & Notification System
This document explains how matches are detected, stored, and how push notifications are triggered.
## Overview
A **match** occurs when both partners in a couple answer a question positively (not "no"). Matches unlock a chat thread for discussing that topic.
## Match Types
| Type | Partner A | Partner B | Description |
|------|-----------|-----------|-------------|
| `yes_yes` | Yes | Yes | Both enthusiastic |
| `yes_maybe` | Yes | Maybe | One enthusiastic, one open |
| `maybe_maybe` | Maybe | Maybe | Both open to exploring |
**No Match Cases:**
- Either partner answers "No" → No match created
- Only one partner has answered → No match yet (waiting)
## Match Detection Flow
```
┌─────────────────────────────────────────────────────────────┐
│ submit-response Edge Function │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Validate user JWT │
│ 2. Get user's couple_id from profiles │
│ 3. UPSERT response (user_id, question_id, answer) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Query partner's response for same question │
│ SELECT answer FROM responses │
│ WHERE couple_id = ? AND question_id = ? AND user_id != ? │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
Partner answered? No partner response
│ │
▼ ▼
calculateMatch(a1, a2) Return { response }
│ (no match yet)
▼
┌─────────────────────────────────────────────────────────────┐
│ function calculateMatch(a1, a2): │
│ if (a1 === "no" || a2 === "no") return null │
│ if (a1 === "yes" && a2 === "yes") return "yes_yes" │
│ if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe"│
│ return "yes_maybe" │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
Match type found null (no match)
│ │
▼ ▼
┌──────────────────────────┐ Return { response }
│ UPSERT match record │
│ (couple_id, question_id) │
└────────────┬─────────────┘
│
▼
┌──────────────────────────┐
│ Invoke send-notification │
│ { couple_id, match_id } │
└────────────┬─────────────┘
│
▼
Return { response, match }
```
## Match Storage
```sql
CREATE TABLE public.matches (
id UUID PRIMARY KEY,
couple_id UUID NOT NULL REFERENCES couples(id),
question_id UUID NOT NULL REFERENCES questions(id),
match_type match_type NOT NULL, -- yes_yes, yes_maybe, maybe_maybe
is_new BOOLEAN DEFAULT TRUE, -- For notification badge
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(couple_id, question_id) -- One match per question per couple
);
```
**UPSERT Behavior:**
Matches use `onConflict: "couple_id,question_id"` so if a user changes their answer, the match type updates rather than creating duplicates.
## Push Notifications
### Match Notification (`send-notification`)
Triggered when a new match is created.
**Input:**
```json
{ "couple_id": "uuid", "match_id": "uuid" }
```
**Behavior:**
1. Fetch all profiles in couple with push tokens
2. Build notification for both partners:
```javascript
{
to: profile.push_token,
title: "It's a match! 💕",
body: "You and your partner matched on something new!",
sound: "default",
data: { match_id, type: "match" }
}
```
3. Send via Expo Push API
**Privacy:** Notification content is generic - doesn't reveal the question topic.
### Message Notification (`send-message-notification`)
Triggered when a chat message is sent.
**Input:**
```json
{ "match_id": "uuid", "sender_id": "uuid" }
```
**Behavior:**
1. Find the match's couple_id
2. Get recipient's push token (partner, not sender)
3. Build notification:
```javascript
{
to: recipient.push_token,
title: "New message",
body: "Your partner sent you a message",
sound: "default",
data: { type: "message", match_id, message_id }
}
```
4. Send via Expo Push API
**Privacy:** Message content not included in notification.
## Client State Management
### Match Store (`src/store/index.ts`)
```typescript
interface MatchState {
matches: Match[];
newMatchesCount: number;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
clearMatches: () => void;
}
```
### Fetching Matches
The `fetchMatches` function:
1. Queries matches with joined question data
2. Fetches all responses to determine who answered first (for two-part display)
3. Counts unread messages per match
4. Calculates new matches count (where `is_new = true`)
```typescript
const { data: matches } = await supabase
.from("matches")
.select(`*, question:questions(*)`)
.order("created_at", { ascending: false });
```
### Match Display
The matches screen shows:
- Question text (user's version for two-part questions)
- Partner's question text (if two-part)
- Match type badge (`YES + YES` or `YES + MAYBE`)
- Unread message indicator
- Date
**Two-Part Question Display Logic:**
```typescript
// Determine which text each person saw based on response timestamps
if (userResponse.created_at > partnerResponse.created_at) {
userText = question.partner_text; // User was second
partnerText = question.text; // Partner was first
}
```
## Chat System
Each match unlocks a chat thread stored in `messages` table:
```sql
CREATE TABLE public.messages (
id UUID PRIMARY KEY,
match_id UUID NOT NULL REFERENCES matches(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
content TEXT,
media_path TEXT, -- Storage path for images
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
);
```
### Real-Time Chat
Messages use Supabase Realtime:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
```
### Message Read Tracking
- `read_at` is `NULL` for unread messages
- Updated when recipient opens chat thread
- Used to calculate unread counts and badges
## Notification Flow Diagram
```
User A swipes "Yes"
│
▼
submit-response
│
├──▶ Save response
│
├──▶ Check partner response
│ │
│ ▼
│ Partner said "Yes"?
│ │
│ ▼
│ Create match (yes_yes)
│ │
└─────────┼──▶ Invoke send-notification
│
▼
┌─────────────────────┐
│ Expo Push Service │
└─────────────────────┘
│
┌─────────┴─────────┐
▼ ▼
User A Phone User B Phone
"It's a match!" "It's a match!"
```
## RLS Policies
Users can only see matches for their couple:
```sql
CREATE POLICY "Users can view their couple matches"
ON public.matches FOR SELECT
USING (
couple_id IN (
SELECT couple_id FROM profiles WHERE id = auth.uid()
)
);
```
## Badge Management
### New Match Badge
- Set to `is_new = TRUE` on match creation
- Updated to `FALSE` when user views matches screen
- Count displayed on tab bar badge
```typescript
// On matches screen focus
useEffect(() => {
if (matches.length > 0) {
markAllAsSeen(); // Clears is_new flags
}
}, [matches.length]);
```
### Unread Message Badge
- Counted per match from messages where `read_at IS NULL`
- Displayed as bubble on match card
- Cleared when entering chat thread