Database Schema v1.0.0
Database: Supabase PostgreSQL 15+ Project: AI-Powered Photo Journaling iOS App Created: 2025-01-11 Status: Implementation in progressOverview
This schema supports an AI-powered photo journaling iOS app with:- Multi-tenant user data isolation via Row Level Security (RLS)
- Freemium subscription model (50 photos/month free, unlimited premium)
- Full-text search on journal entries
- Emotion tagging (12-emotion taxonomy)
- AI prompt generation and selection tracking
- Apple In-App Purchase integration
Schema Diagram
Tables
1. users (extends auth.users)
Stores user profiles and subscription state. Columns:id(UUID, PK, FK → auth.users): User identifieremail(TEXT, NOT NULL, UNIQUE): User email addressdisplay_name(TEXT, NOT NULL): User’s display namebio(TEXT, nullable): User biographyprofile_photo_url(TEXT, nullable): URL to profile photo in Supabase Storagesubscription_tier(TEXT, NOT NULL, DEFAULT ‘free’): ‘free’ or ‘premium’subscription_status(TEXT, nullable): ‘active’, ‘trial’, ‘cancelled’, ‘expired’trial_start_date(TIMESTAMPTZ, nullable): Trial period starttrial_end_date(TIMESTAMPTZ, nullable): Trial period endsubscription_start_date(TIMESTAMPTZ, nullable): Subscription startsubscription_end_date(TIMESTAMPTZ, nullable): Subscription endmonthly_photo_count(INTEGER, NOT NULL, DEFAULT 0): Current month’s photo countlast_photo_reset_date(TIMESTAMPTZ, NOT NULL, DEFAULT NOW()): Last reset timestampcreated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())updated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())deleted_at(TIMESTAMPTZ, nullable): Soft delete timestamp
idx_users_emailonemailidx_users_subscription_tieronsubscription_tieridx_users_deleted_atondeleted_atWHEREdeleted_at IS NULL
- Photo count resets monthly (1st of month via cron job)
- Soft delete:
deleted_atNULL = active, non-NULL = deleted (30-day recovery) - Free tier: max 50 photos/month
- Premium tier: unlimited photos
2. journal_entries
Core table storing all photo journal entries. Columns:id(UUID, PK, DEFAULT gen_random_uuid())user_id(UUID, NOT NULL, FK → users.id ON DELETE CASCADE)photo_url(TEXT, NOT NULL): URL to photo in Supabase Storagephoto_width(INTEGER, NOT NULL): Photo width in pixelsphoto_height(INTEGER, NOT NULL): Photo height in pixelsphoto_size_bytes(INTEGER, NOT NULL): Photo file sizejournal_text(TEXT, NOT NULL): User’s journal entry textai_prompt_used(TEXT, nullable): The AI prompt user selectedlocation_name(TEXT, nullable): Location name (e.g., “San Francisco, CA”)latitude(DECIMAL(10, 8), nullable): GPS latitudelongitude(DECIMAL(11, 8), nullable): GPS longitudephoto_taken_at(TIMESTAMPTZ, nullable): When photo was taken (from EXIF)search_vector(TSVECTOR, GENERATED ALWAYS): Full-text search indexcreated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())updated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())
idx_journal_entries_user_idonuser_ididx_journal_entries_created_atoncreated_at DESCidx_journal_entries_photo_taken_atonphoto_taken_at DESCidx_journal_entries_user_created(composite) on(user_id, created_at DESC)idx_journal_entries_search_vector(GIN) onsearch_vector
search_vectorauto-generated fromjournal_text+ai_prompt_used- Photo limit enforced by trigger (check
users.monthly_photo_count) - Deleting entry cascades to
entry_emotionsandai_prompts
3. emotions
Reference table for 12-emotion taxonomy (read-only). Columns:id(UUID, PK, DEFAULT gen_random_uuid())name(TEXT, NOT NULL, UNIQUE): Emotion namecategory(TEXT, NOT NULL): ‘positive’, ‘reflective’, or ‘challenging’color_hex(TEXT, NOT NULL): Hex color code (e.g., ‘#FF6B6B’)icon_name(TEXT, NOT NULL): Icon identifier (SF Symbols or custom)display_order(INTEGER, NOT NULL): Sort order for UI displaycreated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())
idx_emotions_categoryoncategoryidx_emotions_display_orderondisplay_order
- Positive: Happy, Grateful, Excited, Peaceful
- Reflective: Thoughtful, Nostalgic, Proud, Loving
- Challenging: Sad, Anxious, Frustrated, Hopeful
4. entry_emotions
Junction table linking entries to emotions (many-to-many). Columns:entry_id(UUID, PK, FK → journal_entries.id ON DELETE CASCADE)emotion_id(UUID, PK, FK → emotions.id ON DELETE CASCADE)created_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())
(entry_id, emotion_id)
Indexes:
idx_entry_emotions_entry_idonentry_ididx_entry_emotions_emotion_idonemotion_id
- One entry can have multiple emotions (no hard limit, app enforces max 12)
- Deleting entry cascades delete of associated emotions
5. ai_prompts
Stores AI-generated prompts for entries (3 prompts per entry). Columns:id(UUID, PK, DEFAULT gen_random_uuid())entry_id(UUID, NOT NULL, FK → journal_entries.id ON DELETE CASCADE)prompt_text(TEXT, NOT NULL): The AI-generated promptwas_selected(BOOLEAN, NOT NULL, DEFAULT false): User chose this promptcreated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())
idx_ai_prompts_entry_idonentry_id
- 3 prompts generated per entry (before user selection)
- Max 1 prompt marked
was_selected = trueper entry - Used for analytics (prompt popularity, AI quality tracking)
6. subscriptions
Tracks Premium subscriptions and Apple IAP receipts. Columns:id(UUID, PK, DEFAULT gen_random_uuid())user_id(UUID, NOT NULL, FK → users.id ON DELETE CASCADE)plan(TEXT, NOT NULL): ‘monthly’ or ‘annual’status(TEXT, NOT NULL): ‘trial’, ‘active’, ‘cancelled’, ‘expired’, ‘grace_period’, ‘billing_retry’trial_start_date(TIMESTAMPTZ, nullable)trial_end_date(TIMESTAMPTZ, nullable)current_period_start(TIMESTAMPTZ, NOT NULL)current_period_end(TIMESTAMPTZ, NOT NULL)cancelled_at(TIMESTAMPTZ, nullable)apple_receipt_data(TEXT, nullable): Base64-encoded receiptapple_transaction_id(TEXT, UNIQUE, nullable): Apple transaction IDapple_original_transaction_id(TEXT, nullable): Original transaction IDcreated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())updated_at(TIMESTAMPTZ, NOT NULL, DEFAULT NOW())
idx_subscriptions_user_idonuser_ididx_subscriptions_statusonstatusidx_subscriptions_apple_transaction_idonapple_transaction_id
- Receipt validation happens server-side (backend verifies with Apple)
- Status transitions: trial → active → cancelled → expired
- Grace period: Apple retrying failed payment
- Billing retry: Payment issue, subscription still temporarily active
Functions & Triggers
1. check_photo_limit()
Purpose: Enforce 50 photos/month limit for free users Trigger: BEFORE INSERT onjournal_entries
Logic:
- Get user’s
subscription_tierandmonthly_photo_count - If premium user, allow insert
- If free user with count >= 50, raise exception
- Otherwise, increment
monthly_photo_countand allow insert
2. reset_monthly_photo_counts()
Purpose: Reset photo counts on 1st of each month Schedule: Cron job (daily at 12:01 AM UTC on 1st of month) Logic:- Find all users where
last_photo_reset_dateis in previous month - Set
monthly_photo_count = 0 - Set
last_photo_reset_date = NOW()
3. update_updated_at_column()
Purpose: Auto-updateupdated_at timestamp on row changes
Triggers: BEFORE UPDATE on users, journal_entries, subscriptions
Logic: Set NEW.updated_at = NOW()
Row Level Security (RLS) Policies
All tables have RLS enabled. Policies enforce multi-tenant data isolation.users
- SELECT: Users can view own profile (
auth.uid() = id) - UPDATE: Users can update own profile (
auth.uid() = id)
journal_entries
- SELECT: Users can view own entries (
auth.uid() = user_id) - INSERT: Users can create own entries (
auth.uid() = user_id) - UPDATE: Users can update own entries (
auth.uid() = user_id) - DELETE: Users can delete own entries (
auth.uid() = user_id)
emotions
- SELECT: Publicly readable (all authenticated users can view)
entry_emotions
- SELECT: Users can view emotions for own entries
- INSERT: Users can add emotions to own entries
- DELETE: Users can remove emotions from own entries
ai_prompts
- SELECT: Users can view prompts for own entries
- INSERT: Users can create prompts for own entries (via backend)
subscriptions
- SELECT: Users can view own subscriptions (
auth.uid() = user_id) - INSERT/UPDATE: Only backend can modify (receipt validation)
Performance Targets
| Query Type | Target | Optimization |
|---|---|---|
| Timeline (20 entries) | < 100ms | Composite index (user_id, created_at) |
| Search with filters | < 500ms | GIN index on search_vector |
| Entry detail | < 50ms | Primary key lookup |
| Photo limit check | < 10ms | Trigger function, app caching |
Storage Schema (Supabase Storage)
Bucket:journal-photos
Structure:
- Users can upload/view/delete only in own folder (
{user_id}/) - Authenticated access only
Migration Strategy
Sequential numbered migrations:20250111000001_create_users_table.sql20250111000002_create_journal_entries_table.sql20250111000003_create_emotions_tables.sql20250111000004_create_ai_prompts_table.sql20250111000005_create_subscriptions_table.sql20250111000006_create_functions_and_triggers.sql20250111000007_create_storage_bucket_and_policies.sql
Security Considerations
- RLS Policies: All user data isolated by
auth.uid() - Soft Delete: 30-day recovery period for user accounts
- No Secrets: Connection strings in environment variables
- Receipt Validation: Server-side Apple IAP verification
- Input Validation: Check constraints on enums, foreign keys enforced
- Rate Limiting: Handled at application/API gateway layer
Related Documentation
Version History:
- v1.0.0 (2025-01-11): Initial schema design