# Backend Requirements — AgentMarket (Supabase)
Last updated: 2026-04-04
This document specifies everything needed to connect the React Native frontend to Supabase. It covers the database schema, Row-Level Security policies, storage buckets, and exactly which queries each screen makes. After implementing this file, the app should load all data from Supabase instead of the current mock data.
---
## 1. Stack
| Layer | Technology |
|-------|------------|
| Database | Supabase PostgreSQL |
| Auth | Supabase Auth (email/password, Google OAuth, Apple OAuth) |
| Storage | Supabase Storage (photo uploads) |
| Client | `@supabase/supabase-js` from React Native |
| Real-time | Supabase Realtime (conversations, status updates) |
**No separate backend server is needed for CRUD.** The frontend talks directly to Supabase via the JS client with RLS policies enforcing access control. The AI agent system (Jay's branch) is a separate service that reads/writes to the same Supabase database.
---
## 2. Supabase Project Setup
```
1. Create a Supabase project
2. Get the project URL and anon key
3. Install in frontend: npm install @supabase/supabase-js
4. Create frontend/lib/supabase.ts with:
- SUPABASE_URL (from project settings)
- SUPABASE_ANON_KEY (from project settings)
- createClient() instance
```
---
## 3. Database Schema
### 3.1 `users` (managed by Supabase Auth)
Supabase Auth creates `auth.users` automatically. We extend it with a profile table:
```sql
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, display_name, email)
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'display_name', ''), NEW.email);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
```
### 3.2 `user_settings`
```sql
CREATE TABLE public.user_settings (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
theme_preference TEXT NOT NULL DEFAULT 'system' CHECK (theme_preference IN ('light', 'dark', 'system')),
auto_reply BOOLEAN NOT NULL DEFAULT true,
response_delay TEXT NOT NULL DEFAULT '5 min' CHECK (response_delay IN ('1 min', '5 min', '15 min', '30 min')),
negotiation_style TEXT NOT NULL DEFAULT 'moderate' CHECK (negotiation_style IN ('aggressive', 'moderate', 'passive')),
reply_tone TEXT NOT NULL DEFAULT 'professional' CHECK (reply_tone IN ('professional', 'casual', 'firm')),
notif_new_message BOOLEAN NOT NULL DEFAULT true,
notif_price_drop BOOLEAN NOT NULL DEFAULT true,
notif_deal_closed BOOLEAN NOT NULL DEFAULT true,
notif_listing_expired BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Auto-create settings on signup
CREATE OR REPLACE FUNCTION public.handle_new_user_settings()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.user_settings (user_id) VALUES (NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created_settings
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user_settings();
```
### 3.3 `platform_connections`
```sql
CREATE TABLE public.platform_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('ebay', 'depop', 'mercari', 'offerup', 'facebook')),
username TEXT,
connected BOOLEAN NOT NULL DEFAULT false,
connected_at TIMESTAMPTZ,
UNIQUE(user_id, platform)
);
```
### 3.4 `items`
The core listing table. Each row is one buy or sell listing.
```sql
CREATE TABLE public.items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('buy', 'sell')),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
condition TEXT NOT NULL DEFAULT 'Good',
image_color TEXT NOT NULL DEFAULT '#6EE7B7',
target_price NUMERIC(10,2) NOT NULL,
min_price NUMERIC(10,2),
max_price NUMERIC(10,2),
auto_accept_threshold NUMERIC(10,2),
initial_price NUMERIC(10,2),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'archived')),
quantity INTEGER NOT NULL DEFAULT 1,
negotiation_style TEXT NOT NULL DEFAULT 'moderate' CHECK (negotiation_style IN ('aggressive', 'moderate', 'passive')),
reply_tone TEXT NOT NULL DEFAULT 'professional' CHECK (reply_tone IN ('professional', 'casual', 'firm')),
best_offer NUMERIC(10,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_items_user_id ON public.items(user_id);
CREATE INDEX idx_items_status ON public.items(status);
CREATE INDEX idx_items_type ON public.items(type);
```
### 3.4a `updated_at` Auto-Trigger
Shared trigger function used by any table with an `updated_at` column:
```sql
CREATE OR REPLACE FUNCTION public.set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_items_updated_at
BEFORE UPDATE ON public.items
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER set_user_settings_updated_at
BEFORE UPDATE ON public.user_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER set_market_data_updated_at
BEFORE UPDATE ON public.market_data
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
```
### 3.5 `item_platforms`
Which platforms a listing is active on (many-to-many).
```sql
CREATE TABLE public.item_platforms (
item_id UUID NOT NULL REFERENCES public.items(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('ebay', 'depop', 'mercari', 'offerup', 'facebook')),
PRIMARY KEY (item_id, platform)
);
```
### 3.6 `item_photos`
Ordered photos for each listing.
```sql
CREATE TABLE public.item_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES public.items(id) ON DELETE CASCADE,
photo_url TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_item_photos_item_id ON public.item_photos(item_id);
```
### 3.7 `market_data`
Per-platform pricing snapshot for a listing. Updated by AI agents.
```sql
CREATE TABLE public.market_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES public.items(id) ON DELETE CASCADE,
platform TEXT NOT NULL CHECK (platform IN ('ebay', 'depop', 'mercari', 'offerup', 'facebook')),
best_buy_price NUMERIC(10,2) NOT NULL,
best_sell_price NUMERIC(10,2) NOT NULL,
volume INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(item_id, platform)
);
```
### 3.8 `conversations`
Each conversation is between the AI agent and one counterparty on one platform.
```sql
CREATE TABLE public.conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES public.items(id) ON DELETE CASCADE,
username TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('ebay', 'depop', 'mercari', 'offerup', 'facebook')),
last_message TEXT NOT NULL DEFAULT '',
last_message_at TIMESTAMPTZ NOT NULL DEFAULT now(),
unread BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_conversations_item_id ON public.conversations(item_id);
```
### 3.9 `messages`
Individual messages within a conversation.
```sql
CREATE TABLE public.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES public.conversations(id) ON DELETE CASCADE,
sender TEXT NOT NULL CHECK (sender IN ('agent', 'them')),
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_conversation_id ON public.messages(conversation_id);
```
### 3.10 `completed_trades`
When a deal closes, the AI agent (or user) creates a record here. This is what feeds the P&L chart and the Recent Trades page.
```sql
CREATE TABLE public.completed_trades (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
item_id UUID REFERENCES public.items(id) ON DELETE SET NULL,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('Sold', 'Bought')),
platform TEXT NOT NULL CHECK (platform IN ('ebay', 'depop', 'mercari', 'offerup', 'facebook')),
price NUMERIC(10,2) NOT NULL,
initial_price NUMERIC(10,2),
completed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_completed_trades_user_id ON public.completed_trades(user_id);
CREATE INDEX idx_completed_trades_completed_at ON public.completed_trades(completed_at);
```
---
## 4. Row-Level Security (RLS)
**Enable RLS on all tables.** Every table must have `ALTER TABLE public.
ENABLE ROW LEVEL SECURITY;`
### 4.1 Direct user tables
```sql
-- profiles
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
-- user_settings
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own settings" ON public.user_settings FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can update own settings" ON public.user_settings FOR UPDATE USING (auth.uid() = user_id);
-- platform_connections
ALTER TABLE public.platform_connections ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage own platforms" ON public.platform_connections FOR ALL USING (auth.uid() = user_id);
-- items (no DELETE — users archive instead)
ALTER TABLE public.items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own items" ON public.items FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own items" ON public.items FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own items" ON public.items FOR UPDATE USING (auth.uid() = user_id);
-- completed_trades
ALTER TABLE public.completed_trades ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage own trades" ON public.completed_trades FOR ALL USING (auth.uid() = user_id);
```
### 4.2 Child tables (access via parent ownership)
```sql
-- item_platforms
ALTER TABLE public.item_platforms ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage platforms for own items" ON public.item_platforms FOR ALL
USING (EXISTS (SELECT 1 FROM public.items WHERE items.id = item_platforms.item_id AND items.user_id = auth.uid()));
-- item_photos
ALTER TABLE public.item_photos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage photos for own items" ON public.item_photos FOR ALL
USING (EXISTS (SELECT 1 FROM public.items WHERE items.id = item_photos.item_id AND items.user_id = auth.uid()));
-- market_data (agents write via service_role key which bypasses RLS — no write policy needed)
ALTER TABLE public.market_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read market data for own items" ON public.market_data FOR SELECT
USING (EXISTS (SELECT 1 FROM public.items WHERE items.id = market_data.item_id AND items.user_id = auth.uid()));
-- conversations
ALTER TABLE public.conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read conversations for own items" ON public.conversations FOR ALL
USING (EXISTS (SELECT 1 FROM public.items WHERE items.id = conversations.item_id AND items.user_id = auth.uid()));
-- messages (agents write via service_role key which bypasses RLS — no write policy needed)
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read messages in own conversations" ON public.messages FOR SELECT
USING (EXISTS (
SELECT 1 FROM public.conversations c
JOIN public.items i ON c.item_id = i.id
WHERE c.id = messages.conversation_id AND i.user_id = auth.uid()
));
```
---
## 5. Storage Buckets
```sql
-- Create a public bucket for item photos
INSERT INTO storage.buckets (id, name, public) VALUES ('item-photos', 'item-photos', true);
-- RLS: users can upload to their own folder
CREATE POLICY "Users can upload photos" ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'item-photos' AND auth.uid()::text = (storage.foldername(name))[1]);
CREATE POLICY "Users can delete own photos" ON storage.objects FOR DELETE
USING (bucket_id = 'item-photos' AND auth.uid()::text = (storage.foldername(name))[1]);
CREATE POLICY "Anyone can read photos" ON storage.objects FOR SELECT
USING (bucket_id = 'item-photos');
```
**Upload path convention:** `{user_id}/{item_id}/{filename}`
**Upload flow from frontend:**
```ts
const { data, error } = await supabase.storage
.from('item-photos')
.upload(`${userId}/${itemId}/${Date.now()}.jpg`, file);
const url = supabase.storage.from('item-photos').getPublicUrl(data.path).data.publicUrl;
// Then insert into item_photos table with this URL
```
---
## 6. Frontend Queries by Screen
### 6.1 Home Page (`app/index.tsx`)
**P&L Chart data:**
```ts
const { data: trades } = await supabase
.from('completed_trades')
.select('*')
.eq('user_id', userId)
.not('initial_price', 'is', null)
.order('completed_at', { ascending: true });
```
**Items for carousels:**
```ts
const { data: items } = await supabase
.from('items')
.select(`
*,
item_platforms(platform),
item_photos(id, photo_url, sort_order)
`)
.eq('user_id', userId)
.neq('status', 'archived')
.order('created_at', { ascending: false });
```
### 6.2 Item Detail Page (`app/item/[id].tsx`)
```ts
const { data: item } = await supabase
.from('items')
.select(`
*,
item_platforms(platform),
item_photos(id, photo_url, sort_order),
market_data(platform, best_buy_price, best_sell_price, volume),
conversations(
id, username, platform, last_message, last_message_at, unread,
messages(id, sender, text, created_at)
)
`)
.eq('id', itemId)
.single();
```
### 6.3 Chat Log Page (`app/chat/[id].tsx`)
```ts
// Get conversation with messages
const { data: conversation } = await supabase
.from('conversations')
.select('*, messages(id, sender, text, created_at)')
.eq('id', conversationId)
.single();
// Mark as read
await supabase
.from('conversations')
.update({ unread: false })
.eq('id', conversationId);
```
**Real-time subscription for new messages:**
```ts
supabase
.channel(`messages:${conversationId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `conversation_id=eq.${conversationId}`,
}, (payload) => {
// Append new message to list
})
.subscribe();
```
### 6.4 New Listing (`app/new-listing.tsx`)
```ts
// 1. Create item
const { data: item } = await supabase
.from('items')
.insert({
user_id: userId,
type: 'sell',
name: 'Air Jordan 1',
description: '...',
condition: 'New',
image_color: '#6EE7B7',
target_price: 320,
min_price: 260,
max_price: 380,
initial_price: 85, // optional
negotiation_style: 'moderate',
reply_tone: 'professional',
status: aiActive ? 'active' : 'paused',
quantity: 1,
})
.select()
.single();
// 2. Insert platforms
await supabase
.from('item_platforms')
.insert(selectedPlatforms.map(p => ({ item_id: item.id, platform: p })));
// 3. Upload photos + insert photo records
for (const [i, photo] of photos.entries()) {
const path = `${userId}/${item.id}/${Date.now()}_${i}.jpg`;
await supabase.storage.from('item-photos').upload(path, photo);
const url = supabase.storage.from('item-photos').getPublicUrl(path).data.publicUrl;
await supabase.from('item_photos').insert({ item_id: item.id, photo_url: url, sort_order: i });
}
```
### 6.5 Recent Trades (`app/trades.tsx`)
```ts
const { data: trades } = await supabase
.from('completed_trades')
.select('*')
.eq('user_id', userId)
.order('completed_at', { ascending: false });
```
### 6.6 Settings — Account (`settings/index.tsx`)
```ts
// Read
const { data: profile } = await supabase.from('profiles').select('*').eq('id', userId).single();
const { data: settings } = await supabase.from('user_settings').select('*').eq('user_id', userId).single();
// Update
await supabase.from('profiles').update({ display_name: 'New Name' }).eq('id', userId);
await supabase.from('user_settings').update({ theme_preference: 'dark' }).eq('user_id', userId);
```
### 6.7 Settings — Platforms (`settings/platforms.tsx`)
```ts
const { data: platforms } = await supabase
.from('platform_connections')
.select('*')
.eq('user_id', userId);
// Connect
await supabase
.from('platform_connections')
.upsert({
user_id: userId,
platform: 'ebay',
connected: true,
username: '@reseller_sam',
connected_at: new Date().toISOString(),
});
// Disconnect
await supabase
.from('platform_connections')
.update({ connected: false })
.eq('user_id', userId)
.eq('platform', 'ebay');
```
### 6.8 Settings — Agent Defaults (`settings/agents.tsx`)
```ts
// Same user_settings table
await supabase
.from('user_settings')
.update({ negotiation_style: 'aggressive', reply_tone: 'casual' })
.eq('user_id', userId);
```
### 6.9 Settings — Notifications (`settings/notifications.tsx`)
```ts
await supabase
.from('user_settings')
.update({ notif_new_message: false })
.eq('user_id', userId);
```
---
## 7. Item Status Updates
```ts
// Pause/resume (AI toggle)
await supabase.from('items').update({ status: 'paused' }).eq('id', itemId);
await supabase.from('items').update({ status: 'active' }).eq('id', itemId);
// Archive
await supabase.from('items').update({ status: 'archived' }).eq('id', itemId);
```
---
## 8. Photo Management
### Reorder
```ts
// Update sort_order for all photos of an item
await Promise.all(
photos.map((photo, i) =>
supabase.from('item_photos').update({ sort_order: i }).eq('id', photo.id)
)
);
```
### Delete
```ts
// Delete from storage + table
await supabase.storage.from('item-photos').remove([photo.path]);
await supabase.from('item_photos').delete().eq('id', photoId);
```
---
## 9. P&L Calculation
The frontend computes P&L from `completed_trades`:
```ts
// Sell trade: profit = price - initial_price
// Buy trade: profit = initial_price - price (expected resale - what you paid)
// Trades without initial_price: excluded from P&L chart
function getProfit(trade: CompletedTrade): number | null {
if (trade.initial_price == null) return null;
if (trade.type === 'Sold') return trade.price - trade.initial_price;
return trade.initial_price - trade.price;
}
```
The cumulative P&L is computed client-side by summing profits in chronological order. The chart renders this as a bezier curve.
---
## 10. AI Agent Integration
AI agents (separate service, Jay's branch) access Supabase using the **service_role key** (bypasses RLS).
### What agents read:
- `items` — listing config (platforms, prices, negotiation style, reply tone, status)
- `item_photos` — photos to upload to platforms
- `user_settings` — default agent behavior
### What agents write:
- `messages` — when sending/receiving messages on platforms
- `conversations` — create new conversations, update `last_message`, set `unread`
- `market_data` — periodic platform price updates
- `items.best_offer` — when a better offer comes in
- `completed_trades` — when a deal closes
### Agent reads item config:
```sql
SELECT i.*, array_agg(ip.platform) as platforms
FROM items i
JOIN item_platforms ip ON i.id = ip.item_id
WHERE i.status = 'active'
GROUP BY i.id;
```
### Agent records a completed trade:
```sql
INSERT INTO completed_trades (user_id, item_id, name, type, platform, price, initial_price)
SELECT user_id, id, name,
CASE WHEN type = 'sell' THEN 'Sold' ELSE 'Bought' END,
'ebay', 145.00, initial_price
FROM items WHERE id = '';
-- Then archive the item
UPDATE items SET status = 'archived' WHERE id = '';
```
---
## 11. Realtime Subscriptions
**Required setup:** Enable replication for realtime-enabled tables. Run this in the SQL editor or it will be in the migration:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversations, public.messages, public.items;
```
For live updates without polling:
```ts
// Subscribe to conversation updates for all user's items
supabase
.channel('user-conversations')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'conversations',
}, (payload) => {
// Refresh conversation list
})
.subscribe();
// Subscribe to item status changes (agent paused/activated something)
supabase
.channel('user-items')
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'items',
filter: `user_id=eq.${userId}`,
}, (payload) => {
// Refresh item data
})
.subscribe();
```
---
## 12. Authentication Flow
### 12.1 Supabase Auth Setup
Enable the following providers in the Supabase Dashboard under **Authentication > Providers**:
| Provider | Setup |
|----------|-------|
| Email/Password | Enabled by default. Disable "Confirm email" for dev, enable for production. |
| Google | Create OAuth credentials in Google Cloud Console. Set authorized redirect URI to `https://.supabase.co/auth/v1/callback`. Add Client ID and Secret in Supabase dashboard. |
| Apple | Register a Services ID with Apple Developer. Configure Sign in with Apple. Set return URL to `https://.supabase.co/auth/v1/callback`. Add Service ID and Secret Key in Supabase dashboard. |
### 12.2 Email/Password Sign Up
```ts
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'securepassword',
options: {
data: {
display_name: 'Sam', // stored in raw_user_meta_data, used by handle_new_user trigger
},
},
});
```
The `on_auth_user_created` trigger auto-creates a row in `profiles` and `user_settings`.
### 12.3 Email/Password Sign In
```ts
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'securepassword',
});
```
### 12.4 Google OAuth
For React Native, use `expo-auth-session` or `expo-web-browser` to handle the OAuth redirect:
```ts
import * as WebBrowser from 'expo-web-browser';
import { makeRedirectUri } from 'expo-auth-session';
const redirectUri = makeRedirectUri();
const handleGoogleSignIn = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: redirectUri,
skipBrowserRedirect: true,
},
});
if (data?.url) {
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectUri);
if (result.type === 'success') {
const url = new URL(result.url);
const access_token = url.searchParams.get('access_token');
const refresh_token = url.searchParams.get('refresh_token');
if (access_token && refresh_token) {
await supabase.auth.setSession({ access_token, refresh_token });
}
}
}
};
```
**Required packages:** `expo-web-browser`, `expo-auth-session`
### 12.5 Apple OAuth (iOS only)
```ts
import * as AppleAuthentication from 'expo-apple-authentication';
const handleAppleSignIn = async () => {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (credential.identityToken) {
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: credential.identityToken,
});
}
};
```
**Required package:** `expo-apple-authentication`
### 12.6 Profile Trigger for OAuth Users
The existing `handle_new_user` trigger works for OAuth too. For Google/Apple, the display name comes from the provider's metadata:
```sql
-- Updated trigger to handle OAuth display names
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, display_name, email)
VALUES (
NEW.id,
COALESCE(
NEW.raw_user_meta_data->>'display_name',
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'name',
''
),
COALESCE(NEW.email, '')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
### 12.7 Session Persistence
Supabase JS client auto-persists the session in `AsyncStorage` (React Native) or `localStorage` (web). On app launch, call:
```ts
const { data: { session } } = await supabase.auth.getSession();
```
### 12.8 Auth State Listener
Set up in the root layout so the app reacts to sign-in/sign-out:
```ts
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_IN') {
router.replace('/');
} else if (event === 'SIGNED_OUT') {
router.replace('/auth/sign-in');
}
}
);
return () => subscription.unsubscribe();
}, []);
```
### 12.9 Sign Out
```ts
await supabase.auth.signOut();
```
### 12.10 Getting Current User ID
```ts
const userId = (await supabase.auth.getUser()).data.user?.id;
```
This `userId` is what all queries use for `user_id` and what RLS checks via `auth.uid()`.
---
## 13. Error Handling
All Supabase calls return `{ data, error }`. The frontend should handle errors consistently:
```ts
const { data, error } = await supabase.from('items').select('*').eq('user_id', userId);
if (error) {
// Log for debugging
console.error('Failed to load items:', error.message);
// Show user-friendly message
Alert.alert('Error', 'Could not load your listings. Please try again.');
return;
}
```
### Common error scenarios
| Scenario | Cause | Handling |
|----------|-------|----------|
| `error.code === 'PGRST301'` | RLS denied access | User is likely signed out — redirect to sign-in |
| `error.code === '23505'` | Unique constraint violation | Duplicate entry — show "already exists" message |
| `error.code === '23503'` | Foreign key violation | Referenced item was deleted — refresh the list |
| `error.message` contains `JWT expired` | Session expired | Call `supabase.auth.refreshSession()` or redirect to sign-in |
| Network error (no `error.code`) | Offline / timeout | Show "No internet connection" message |
### Wrapper pattern (optional)
```ts
async function query(promise: Promise<{ data: T | null; error: any }>): Promise {
const { data, error } = await promise;
if (error) throw new Error(error.message);
return data as T;
}
```
---
## 14. Frontend Integration Checklist
After implementing the Supabase schema above, the frontend developer needs to:
1. [ ] Install `@supabase/supabase-js`, `expo-web-browser`, `expo-auth-session`, `expo-apple-authentication` and create `lib/supabase.ts`
2. [ ] Wire up `auth/sign-in.tsx` and `auth/sign-up.tsx` to Supabase Auth (replace TODO stubs)
3. [ ] Add `onAuthStateChange` listener in `_layout.tsx` to redirect on sign-in/sign-out
4. [ ] Enable Google and Apple providers in Supabase Dashboard
5. [ ] Replace `mockItems` import in `index.tsx` with Supabase query
6. [ ] Replace `TRADE_HISTORY` in `trades.tsx` with Supabase query
7. [ ] Replace static settings data with `user_settings` + `profiles` queries
8. [ ] Replace static platform data with `platform_connections` query
9. [ ] Wire up `new-listing.tsx` form to `items` + `item_platforms` + `item_photos` inserts
10. [ ] Wire up item detail page to Supabase query with nested selects
11. [ ] Wire up chat page to messages query + realtime subscription
12. [ ] Wire up photo upload to Supabase Storage
13. [ ] Wire up AI toggle to `items.status` update
14. [ ] Wire up archive to `items.status` update
15. [ ] Wire up P&L chart to `completed_trades` query
---
## 15. Column Name Mapping
The frontend uses camelCase. Supabase uses snake_case. Use the Supabase JS client's built-in camelCase transform or map manually:
| Frontend | Supabase Column |
|----------|----------------|
| `targetPrice` | `target_price` |
| `minPrice` | `min_price` |
| `maxPrice` | `max_price` |
| `autoAcceptThreshold` | `auto_accept_threshold` |
| `initialPrice` | `initial_price` |
| `imageColor` | `image_color` |
| `bestOffer` | `best_offer` |
| `negotiationStyle` | `negotiation_style` |
| `replyTone` | `reply_tone` |
| `bestBuyPrice` | `best_buy_price` |
| `bestSellPrice` | `best_sell_price` |
| `lastMessage` | `last_message` |
| `lastMessageAt` / `timestamp` (Conversation) | `last_message_at` |
| `timestamp` (Message) | `created_at` |
| `photoUrl` | `photo_url` |
| `sortOrder` | `sort_order` |
| `completedAt` | `completed_at` |
| `createdAt` | `created_at` |
| `updatedAt` | `updated_at` |
---
## 16. Seed Data
Run this after creating a test user via sign-up. Replace `TEST_USER_ID` with the UUID from `auth.users`.
```sql
-- ============================================================
-- Replace this with your test user's UUID after signing up
-- ============================================================
DO $$
DECLARE
uid UUID := 'TEST_USER_ID'; -- <-- REPLACE THIS
item1 UUID := gen_random_uuid();
item2 UUID := gen_random_uuid();
item3 UUID := gen_random_uuid();
item4 UUID := gen_random_uuid();
item5 UUID := gen_random_uuid();
conv1 UUID := gen_random_uuid();
conv2 UUID := gen_random_uuid();
conv3 UUID := gen_random_uuid();
conv4 UUID := gen_random_uuid();
conv5 UUID := gen_random_uuid();
conv6 UUID := gen_random_uuid();
BEGIN
-- ============================================================
-- Items (matches mockItems in frontend/data/mockData.ts)
-- ============================================================
INSERT INTO public.items (id, user_id, type, name, description, condition, image_color, target_price, min_price, max_price, auto_accept_threshold, initial_price, status, quantity, negotiation_style, reply_tone, best_offer) VALUES
(item1, uid, 'sell', 'Air Jordan 1 Retro High OG', 'Chicago colorway, DS (deadstock). Box included. Size 10.', 'New', '#FCA5A5', 320, 260, 380, 300, 85, 'active', 1, 'moderate', 'professional', 295),
(item2, uid, 'sell', 'Sony WH-1000XM4', 'Excellent condition, barely used. Midnight blue. All accessories included.', 'Like New', '#93C5FD', 190, 150, 220, 175, 45, 'active', 1, 'passive', 'casual', NULL),
(item3, uid, 'sell', 'North Face Nuptse 700', 'Vintage 90s Nuptse puffer. Navy blue. Size M. Minor fade on left arm.', 'Good', '#6EE7B7', 145, 110, 180, NULL, NULL, 'paused', 1, 'moderate', 'casual', NULL),
(item4, uid, 'buy', 'Canon AE-1 Program', 'Looking for a clean body with working meter and shutter. Black preferred.', 'Good', '#FCD34D', 85, 60, 120, 90, NULL, 'active', 1, 'aggressive', 'professional', 95),
(item5, uid, 'buy', 'Supreme Box Logo Hoodie FW20', 'Black or white preferred. Size L. Must be verified authentic.', 'Good', '#F9A8D4', 380, 300, 450, 400, NULL, 'active', 1, 'moderate', 'professional', NULL);
-- ============================================================
-- Item Platforms
-- ============================================================
INSERT INTO public.item_platforms (item_id, platform) VALUES
(item1, 'depop'), (item1, 'ebay'), (item1, 'mercari'),
(item2, 'depop'), (item2, 'facebook'), (item2, 'mercari'),
(item3, 'depop'), (item3, 'ebay'),
(item4, 'ebay'), (item4, 'depop'), (item4, 'mercari'), (item4, 'offerup'),
(item5, 'depop'), (item5, 'ebay'), (item5, 'mercari');
-- ============================================================
-- Item Photos
-- ============================================================
INSERT INTO public.item_photos (item_id, photo_url, sort_order) VALUES
(item1, 'https://picsum.photos/seed/jordan1a/400/400', 0),
(item1, 'https://picsum.photos/seed/jordan1b/400/400', 1),
(item1, 'https://picsum.photos/seed/jordan1c/400/400', 2),
(item2, 'https://picsum.photos/seed/sonyxm4a/400/400', 0),
(item2, 'https://picsum.photos/seed/sonyxm4b/400/400', 1),
(item3, 'https://picsum.photos/seed/nuptse700/400/400', 0),
(item5, 'https://picsum.photos/seed/supremebogo/400/400', 0),
(item5, 'https://picsum.photos/seed/supremebogo2/400/400', 1);
-- ============================================================
-- Market Data
-- ============================================================
INSERT INTO public.market_data (item_id, platform, best_buy_price, best_sell_price, volume) VALUES
(item1, 'depop', 299, 315, 42),
(item1, 'ebay', 310, 332, 128),
(item1, 'mercari', 285, 298, 67),
(item2, 'depop', 170, 185, 23),
(item2, 'facebook', 160, 172, 15),
(item2, 'mercari', 180, 195, 41),
(item3, 'depop', 130, 142, 19),
(item3, 'ebay', 145, 158, 34),
(item4, 'ebay', 85, 98, 87),
(item4, 'depop', 95, 110, 31),
(item4, 'mercari', 80, 89, 44),
(item4, 'offerup', 65, 75, 12),
(item5, 'depop', 390, 410, 8),
(item5, 'ebay', 405, 425, 22),
(item5, 'mercari', 375, 395, 11);
-- ============================================================
-- Conversations
-- ============================================================
INSERT INTO public.conversations (id, item_id, username, platform, last_message, last_message_at, unread) VALUES
(conv1, item1, 'sneaker_kylie', 'depop', 'Would you take $280?', now() - interval '2 minutes', true),
(conv2, item1, 'j1collector', 'ebay', 'Offer of $295 submitted', now() - interval '1 hour', false),
(conv3, item2, 'techwatcher', 'mercari', 'Do they come with the case?', now() - interval '30 minutes', true),
(conv4, item4, 'vintage_photo_co', 'depop', 'I can do $95 shipped.', now() - interval '15 minutes', true),
(conv5, item4, 'filmcameraseller', 'ebay', 'Offer sent: $80', now() - interval '2 hours', false),
(conv6, item5, 'supreme_resells', 'depop', 'Can''t go lower than $400.', now() - interval '45 minutes', false);
-- ============================================================
-- Messages
-- ============================================================
INSERT INTO public.messages (conversation_id, sender, text, created_at) VALUES
-- conv1: sneaker_kylie <> agent (Air Jordan 1)
(conv1, 'them', 'Hey! Love these. Are they still available?', now() - interval '15 minutes'),
(conv1, 'agent', 'Hi! Yes, still available. DS with original box. Happy to answer any questions!', now() - interval '14 minutes'),
(conv1, 'them', 'Would you take $280?', now() - interval '6 minutes'),
(conv1, 'agent', 'Thanks for the offer! I''m firm at $310 given recent eBay sold comps averaging $330. Would you meet me at $310?', now() - interval '5 minutes'),
-- conv2: j1collector <> agent (Air Jordan 1)
(conv2, 'them', 'Are these authentic?', now() - interval '2 hours'),
(conv2, 'agent', 'Absolutely 100% authentic. I can provide purchase receipt and authentication photos.', now() - interval '119 minutes'),
(conv2, 'them', 'Offer of $295 submitted', now() - interval '90 minutes'),
-- conv3: techwatcher <> agent (Sony XM4)
(conv3, 'them', 'Do they come with the case?', now() - interval '35 minutes'),
(conv3, 'agent', 'Yes! Comes with original Sony carry case, charging cable, aux cable, and all documentation.', now() - interval '34 minutes'),
-- conv4: vintage_photo_co <> agent (Canon AE-1)
(conv4, 'agent', 'Hi! I''ve seen similar ones sell for around $80–90 recently. Would you consider $75 shipped? Happy to pay right away.', now() - interval '50 minutes'),
(conv4, 'them', 'Hmm, I think it''s worth more. Shutter sounds perfect on this one.', now() - interval '33 minutes'),
(conv4, 'agent', 'Totally fair! Could you meet me at $85? I''m ready to buy today.', now() - interval '32 minutes'),
(conv4, 'them', 'I can do $95 shipped.', now() - interval '17 minutes'),
-- conv5: filmcameraseller <> agent (Canon AE-1)
(conv5, 'agent', 'Hi! I''d like to offer $80 shipped. Based on recent sales data, this is a fair market offer — I can pay immediately.', now() - interval '3 hours'),
(conv5, 'them', 'Offer sent: $80', now() - interval '3 hours'),
-- conv6: supreme_resells <> agent (Supreme Hoodie)
(conv6, 'agent', 'Hey! Recent eBay sold prices average around $380 for FW20 box logo. Would you consider $360?', now() - interval '1 hour'),
(conv6, 'them', 'Can''t go lower than $400.', now() - interval '45 minutes');
-- ============================================================
-- Completed Trades (matches TRADE_HISTORY in frontend/app/trades.tsx)
-- ============================================================
INSERT INTO public.completed_trades (user_id, name, type, platform, price, initial_price, completed_at) VALUES
(uid, 'Nike Dunk Low Panda', 'Sold', 'ebay', 145, 93, '2026-03-28T12:00:00Z'),
(uid, 'Vintage Levi 501s', 'Sold', 'depop', 68, 12, '2026-03-25T12:00:00Z'),
(uid, 'Canon AE-1 Body', 'Bought', 'mercari', 82, NULL, '2026-03-22T12:00:00Z'),
(uid, 'Ray-Ban Aviators', 'Sold', 'facebook', 85, 95, '2026-03-18T12:00:00Z'),
(uid, 'Bose QC45', 'Sold', 'ebay', 195, 120, '2026-03-15T12:00:00Z'),
(uid, 'Supreme Beanie', 'Bought', 'depop', 45, NULL, '2026-03-12T12:00:00Z'),
(uid, 'PS5 DualSense', 'Sold', 'mercari', 42, 28, '2026-03-10T12:00:00Z'),
(uid, 'Patagonia Fleece', 'Sold', 'ebay', 78, 15, '2026-03-08T12:00:00Z'),
(uid, 'AirPods Pro 2', 'Sold', 'facebook', 165, 130, '2026-03-05T12:00:00Z'),
(uid, 'Vintage Polaroid', 'Bought', 'ebay', 55, 120, '2026-03-02T12:00:00Z'),
(uid, 'North Face Puffer', 'Sold', 'depop', 125, 35, '2026-02-28T12:00:00Z'),
(uid, 'Mechanical Keyboard', 'Sold', 'mercari', 95, NULL, '2026-02-25T12:00:00Z');
-- ============================================================
-- Platform Connections (defaults for test user)
-- ============================================================
INSERT INTO public.platform_connections (user_id, platform, username, connected, connected_at) VALUES
(uid, 'ebay', '@reseller_sam', true, now()),
(uid, 'depop', '@reseller_sam', true, now()),
(uid, 'mercari', '@reseller_sam', true, now()),
(uid, 'offerup', NULL, false, NULL),
(uid, 'facebook', NULL, false, NULL);
END $$;
```
---
## 17. Master Agent Config Endpoint
The frontend includes a persistent floating action button (FAB) that links users to the project's public Fetch.ai master agent on ASI:One. The backend must expose a lightweight config endpoint so the frontend can retrieve the agent's address at runtime.
### 17.1 Endpoint
```
GET /config
```
**Response:**
```json
{
"resale_copilot_agent_address": "agent1q..."
}
```
- If no agent is registered or the address is unavailable, return an empty string for the value.
- This endpoint does **not** require authentication — it returns public project-level config.
### 17.2 Frontend Usage
The `MasterAgentFAB` component (`components/MasterAgentFAB.tsx`) fetches this endpoint on mount. If the address is present, it renders a FAB that opens `https://asi1.ai/chat?agent=` in the device browser. If the address is empty or the fetch fails, the button is hidden.
### 17.3 Backend Implementation Notes
- This can be a simple static JSON response from a lightweight HTTP server (e.g., FastAPI, Express) or a Supabase Edge Function.
- The agent address is the stable on-chain address of the `resale_copilot_agent` registered with Fetch.ai / ASI:One.
- The frontend currently uses a placeholder backend URL (`http://localhost:8000`). Update this when the backend is deployed.