Skip to Content
📚 MyStoryFlow Docs — Your guide to preserving family stories
FeaturesTrial ExperienceTechnical Architecture

Technical Architecture

This document covers the database schema, API routes, authentication flow, and data management for the Trial Experience feature.

Database Schema

New Table: trial_sessions

Tracks each trial session with timing, content stats, and conversion status.

CREATE TABLE trial_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE, started_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- started_at + 30 minutes status TEXT CHECK (status IN ('active', 'expired', 'converted')) DEFAULT 'active', converted_at TIMESTAMPTZ, conversation_count INTEGER DEFAULT 0, recording_count INTEGER DEFAULT 0, total_audio_seconds INTEGER DEFAULT 0, story_id UUID REFERENCES stories(id), -- Set after story generation ip_address INET, created_at TIMESTAMPTZ DEFAULT NOW() ); -- RLS: Users can manage their own trial session CREATE POLICY "trial_session_policy" ON trial_sessions FOR ALL TO authenticated USING (user_id = auth.uid()); -- Indexes for common queries CREATE INDEX idx_trial_sessions_user ON trial_sessions(user_id); CREATE INDEX idx_trial_sessions_status ON trial_sessions(status); CREATE INDEX idx_trial_sessions_expires ON trial_sessions(expires_at);

Modifications to Existing Tables

-- Add trial columns to ai_conversations ALTER TABLE ai_conversations ADD COLUMN trial_session_id UUID REFERENCES trial_sessions(id), ADD COLUMN is_trial BOOLEAN DEFAULT false; -- Add trial columns to recordings ALTER TABLE recordings ADD COLUMN trial_session_id UUID REFERENCES trial_sessions(id), ADD COLUMN is_trial BOOLEAN DEFAULT false; -- Indexes for trial content queries CREATE INDEX idx_ai_conversations_trial ON ai_conversations(trial_session_id); CREATE INDEX idx_recordings_trial ON recordings(trial_session_id);

API Routes

Route Overview

RouteMethodPurposeAuth
/api/trial/startPOSTSign in anonymously, create trial_sessionNone
/api/trial/statusGETGet time remaining, content stats, quality scoreAnonymous
/api/trial/validatePOSTAI validation of content qualityAnonymous
/api/trial/convertPOSTLink trial data to user, start story generationAuthenticated
/api/trial/story-statusGETCheck story generation progressAuthenticated

/api/trial/start/route.ts

Creates an anonymous user and trial session.

import { createClient } from '@/lib/supabase/server' import { NextRequest, NextResponse } from 'next/server' export async function POST(request: NextRequest) { const supabase = await createClient() // Sign in anonymously (creates real user with is_anonymous = true) const { data: authData, error: authError } = await supabase.auth.signInAnonymously() if (authError) { return NextResponse.json({ error: authError.message }, { status: 400 }) } // Create trial session (30 minutes) const expiresAt = new Date(Date.now() + 30 * 60 * 1000) const { data: session, error } = await supabase .from('trial_sessions') .insert({ user_id: authData.user.id, expires_at: expiresAt.toISOString(), ip_address: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') }) .select() .single() if (error) { return NextResponse.json({ error: error.message }, { status: 500 }) } return NextResponse.json({ sessionId: session.id, expiresAt: session.expires_at, timeRemaining: 1800, // 30 minutes in seconds userId: authData.user.id }) }

/api/trial/status/route.ts

Returns current trial status including time remaining and content quality.

export async function GET(request: NextRequest) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) } const { data: session } = await supabase .from('trial_sessions') .select('*') .eq('user_id', user.id) .single() if (!session) { return NextResponse.json({ error: 'No trial session found' }, { status: 404 }) } const now = new Date() const expiresAt = new Date(session.expires_at) const timeRemaining = Math.max(0, Math.floor((expiresAt.getTime() - now.getTime()) / 1000)) const isExpired = timeRemaining === 0 // Calculate quality score based on content const qualityScore = calculateQualityScore(session) return NextResponse.json({ sessionId: session.id, status: isExpired ? 'expired' : session.status, timeRemaining, isExpired, conversationCount: session.conversation_count, recordingCount: session.recording_count, totalAudioSeconds: session.total_audio_seconds, qualityScore, isStoryReady: qualityScore >= 70 }) } function calculateQualityScore(session: TrialSession): number { // Voice recording: 10+ minutes = 100% if (session.total_audio_seconds >= 600) return 100 if (session.total_audio_seconds >= 300) return 70 if (session.total_audio_seconds >= 120) return 40 // AI conversation: 10+ meaningful exchanges = 100% if (session.conversation_count >= 10) return 100 if (session.conversation_count >= 5) return 60 return Math.max( (session.total_audio_seconds / 600) * 100, (session.conversation_count / 10) * 100 ) }

/api/trial/convert/route.ts

Links trial data to authenticated user and starts story generation.

export async function POST(request: NextRequest) { const supabase = await createClient() const { trialSessionId } = await request.json() const { data: { user } } = await supabase.auth.getUser() if (!user || user.is_anonymous) { return NextResponse.json({ error: 'Must be authenticated' }, { status: 401 }) } // Update trial session await supabase .from('trial_sessions') .update({ status: 'converted', converted_at: new Date().toISOString() }) .eq('id', trialSessionId) // Link AI conversations to permanent user await supabase .from('ai_conversations') .update({ user_id: user.id, is_trial: false }) .eq('trial_session_id', trialSessionId) // Link recordings to permanent user await supabase .from('recordings') .update({ user_id: user.id, is_trial: false }) .eq('trial_session_id', trialSessionId) // Queue async story generation const { data: job } = await supabase .from('story_generation_jobs') .insert({ trial_session_id: trialSessionId, user_id: user.id, status: 'pending' }) .select() .single() // Trigger background job (Edge Function or queue) await triggerStoryGeneration(job.id) return NextResponse.json({ success: true, jobId: job.id }) }

Authentication Flow

Supabase Anonymous Auth

We use Supabase’s built-in anonymous authentication for trial users:

// Creates a real user with is_anonymous = true const { data, error } = await supabase.auth.signInAnonymously()

Benefits:

  • Real user_id for database relations
  • Works with existing RLS policies
  • Seamless upgrade via linkIdentity()

Account Upgrade

When a trial user signs up, we link the identities:

// During signup with OAuth const { data, error } = await supabase.auth.linkIdentity({ provider: 'google' // or 'apple', etc. }) // During signup with email const { data, error } = await supabase.auth.updateUser({ email: 'user@example.com', password: 'password123' })

This preserves the user_id and all linked trial data.

Middleware Configuration

File: /lib/supabase/middleware.ts

Allow trial routes for anonymous users:

export async function updateSession(request: NextRequest) { const pathname = request.nextUrl.pathname // Trial routes accessible to anonymous users const isTrialRoute = pathname.startsWith('/trial') || pathname.startsWith('/api/trial') if (isTrialRoute) { const { data: { user } } = await supabase.auth.getUser() // Permanent user → redirect to dashboard if (user && !user.is_anonymous) { return NextResponse.redirect(new URL('/dashboard', request.url)) } // Anonymous or no user → allow trial access return response } // Standard auth flow for other routes... }

Data Flow Diagram

Route Structure

apps/web-app/app/ ├── trial/ │ ├── page.tsx # Mode selection (AI vs Voice) │ ├── layout.tsx # Layout with timer bar │ ├── conversation/ │ │ └── page.tsx # AI conversation trial mode │ └── recording/ │ └── page.tsx # Voice recording trial mode └── api/trial/ ├── start/route.ts # Start trial session ├── status/route.ts # Get trial status ├── validate/route.ts # Content validation ├── convert/route.ts # Link to user account └── story-status/route.ts # Check story generation

Data Cleanup Strategy

Daily cron job handles abandoned trial data:

-- Mark expired sessions (1 hour past expiration) UPDATE trial_sessions SET status = 'expired' WHERE status = 'active' AND expires_at < NOW() - INTERVAL '1 hour'; -- Delete abandoned data after 7 days DELETE FROM trial_sessions WHERE status IN ('active', 'expired') AND created_at < NOW() - INTERVAL '7 days'; -- Cascade deletes anonymous users with no converted trial DELETE FROM auth.users WHERE is_anonymous = true AND id NOT IN ( SELECT user_id FROM trial_sessions WHERE status = 'converted' ) AND created_at < NOW() - INTERVAL '7 days';

Next Steps