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
| Route | Method | Purpose | Auth |
|---|---|---|---|
/api/trial/start | POST | Sign in anonymously, create trial_session | None |
/api/trial/status | GET | Get time remaining, content stats, quality score | Anonymous |
/api/trial/validate | POST | AI validation of content quality | Anonymous |
/api/trial/convert | POST | Link trial data to user, start story generation | Authenticated |
/api/trial/story-status | GET | Check story generation progress | Authenticated |
/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_idfor 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 generationData 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
- AI Integration - Content validation and story generation
- Implementation Guide - Step-by-step development