F022 - Word Count & Analysis Limits
Objective
Implement comprehensive usage tracking to enforce subscription limits, monitor resource consumption, and provide usage analytics to users.
Quick Implementation
Using NextSaaS Components
- Progress bars for usage visualization
- Alert components for limit warnings
- DataGrid for usage history
- Card components for statistics
New Requirements
- Real-time usage calculation
- Limit enforcement middleware
- Usage reset scheduling
- Overage handling
MVP Implementation
1. Database Schema Enhancement
-- Usage limits by resource type
CREATE TABLE analyzer.usage_limits (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
plan_id UUID REFERENCES analyzer.subscription_plans(id),
resource_type VARCHAR(50) NOT NULL, -- 'analysis', 'word_count', 'export', 'consultation'
limit_value INTEGER NOT NULL, -- -1 for unlimited
period VARCHAR(20) NOT NULL, -- 'monthly', 'daily', 'per_use'
created_at TIMESTAMP DEFAULT NOW()
);
-- Usage aggregates for performance
CREATE TABLE analyzer.usage_aggregates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
resource_type VARCHAR(50) NOT NULL,
total_usage INTEGER NOT NULL DEFAULT 0,
last_updated TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, period_start, resource_type)
);
-- Usage alerts
CREATE TABLE analyzer.usage_alerts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id),
alert_type VARCHAR(50) NOT NULL, -- 'approaching_limit', 'limit_reached', 'overage'
resource_type VARCHAR(50) NOT NULL,
threshold_percentage INTEGER,
sent_at TIMESTAMP DEFAULT NOW()
);
-- Insert default limits
INSERT INTO analyzer.usage_limits (plan_id, resource_type, limit_value, period)
SELECT
sp.id,
'analysis',
COALESCE((sp.limits->>'analyses_per_month')::int, 0),
'monthly'
FROM subscription_plans sp;
INSERT INTO analyzer.usage_limits (plan_id, resource_type, limit_value, period)
SELECT
sp.id,
'word_count',
COALESCE((sp.limits->>'max_words_per_analysis')::int, 5000),
'per_use'
FROM subscription_plans sp;
-- Function to update usage aggregates
CREATE OR REPLACE FUNCTION update_usage_aggregate()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO analyzer.usage_aggregates (
user_id,
period_start,
period_end,
resource_type,
total_usage
) VALUES (
NEW.user_id,
date_trunc('month', NEW.recorded_at),
date_trunc('month', NEW.recorded_at) + interval '1 month' - interval '1 day',
NEW.resource_type,
NEW.quantity
)
ON CONFLICT (user_id, period_start, resource_type)
DO UPDATE SET
total_usage = usage_aggregates.total_usage + NEW.quantity,
last_updated = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_usage_aggregate
AFTER INSERT ON usage_records
FOR EACH ROW
EXECUTE FUNCTION update_usage_aggregate();2. Usage Tracking Service
// packages/usage/src/services/usage-tracker.ts
import { createClient } from '@mystoryflow/database/server'
interface UsageCheck {
allowed: boolean
currentUsage: number
limit: number
remaining: number
percentageUsed: number
willExceedLimit: boolean
}
export class UsageTracker {
private supabase = getSupabaseBrowserClient()
async checkUsage(
userId: string,
resourceType: string,
requestedAmount: number = 1
): Promise<UsageCheck> {
// Get user's plan and limits
const { data: subscription } = await this.supabase
.from('analyzer.user_subscriptions')
.select(`
*,
subscription_plans (
*,
usage_limits (*)
)
`)
.eq('user_id', userId)
.eq('status', 'active')
.single()
// Default to free plan if no active subscription
const plan = subscription?.subscription_plans || await this.getFreePlan()
const limit = this.getResourceLimit(plan, resourceType)
// Get current usage
const currentUsage = await this.getCurrentUsage(userId, resourceType)
// Calculate metrics
const remaining = Math.max(0, limit - currentUsage)
const percentageUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
const willExceedLimit = currentUsage + requestedAmount > limit
// Check alerts
await this.checkAndSendAlerts(userId, resourceType, percentageUsed)
return {
allowed: !willExceedLimit || limit === -1,
currentUsage,
limit,
remaining,
percentageUsed: Math.round(percentageUsed),
willExceedLimit
}
}
async recordUsage(
userId: string,
resourceType: string,
amount: number,
metadata?: any
): Promise<void> {
// Check if usage is allowed
const check = await this.checkUsage(userId, resourceType, amount)
if (!check.allowed) {
throw new Error(`Usage limit exceeded for ${resourceType}`)
}
// Record the usage
await this.supabase
.from('analyzer.usage_records')
.insert({
user_id: userId,
resource_type: resourceType,
quantity: amount,
metadata
})
}
async getUsageSummary(userId: string): Promise<any> {
// Get current period
const periodStart = new Date()
periodStart.setDate(1)
periodStart.setHours(0, 0, 0, 0)
// Get aggregated usage
const { data: usage } = await this.supabase
.from('analyzer.usage_aggregates')
.select('*')
.eq('user_id', userId)
.gte('period_start', periodStart.toISOString())
// Get user's limits
const { data: subscription } = await this.supabase
.from('analyzer.user_subscriptions')
.select(`
subscription_plans (
*,
usage_limits (*)
)
`)
.eq('user_id', userId)
.eq('status', 'active')
.single()
const plan = subscription?.subscription_plans || await this.getFreePlan()
// Build summary
const summary: any = {}
for (const limit of plan.usage_limits || []) {
const usageRecord = usage?.find(u => u.resource_type === limit.resource_type)
const used = usageRecord?.total_usage || 0
summary[limit.resource_type] = {
used,
limit: limit.limit_value,
remaining: Math.max(0, limit.limit_value - used),
percentage: limit.limit_value > 0
? Math.round((used / limit.limit_value) * 100)
: 0,
unlimited: limit.limit_value === -1
}
}
return summary
}
private async getCurrentUsage(
userId: string,
resourceType: string
): Promise<number> {
const periodStart = new Date()
periodStart.setDate(1)
periodStart.setHours(0, 0, 0, 0)
const { data } = await this.supabase
.from('analyzer.usage_aggregates')
.select('total_usage')
.eq('user_id', userId)
.eq('resource_type', resourceType)
.gte('period_start', periodStart.toISOString())
.single()
return data?.total_usage || 0
}
private getResourceLimit(plan: any, resourceType: string): number {
const limit = plan.usage_limits?.find(
(l: any) => l.resource_type === resourceType
)
return limit?.limit_value || 0
}
private async checkAndSendAlerts(
userId: string,
resourceType: string,
percentageUsed: number
): Promise<void> {
// Check if we need to send alerts
const thresholds = [50, 80, 90, 100]
for (const threshold of thresholds) {
if (percentageUsed >= threshold) {
const alertType = threshold === 100 ? 'limit_reached' : 'approaching_limit'
// Check if alert already sent
const { data: existing } = await this.supabase
.from('analyzer.usage_alerts')
.select('id')
.eq('user_id', userId)
.eq('resource_type', resourceType)
.eq('threshold_percentage', threshold)
.gte('sent_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.single()
if (!existing) {
// Send alert
await this.supabase
.from('analyzer.usage_alerts')
.insert({
user_id: userId,
alert_type: alertType,
resource_type: resourceType,
threshold_percentage: threshold
})
// Trigger notification
await this.sendUsageNotification(userId, alertType, resourceType, threshold)
}
}
}
}
private async sendUsageNotification(
userId: string,
alertType: string,
resourceType: string,
threshold: number
): Promise<void> {
// Import notification service
const { NotificationService } = await import('@/packages/notifications')
const notificationService = new NotificationService()
const messages = {
approaching_limit: `You've used ${threshold}% of your monthly ${resourceType} limit`,
limit_reached: `You've reached your monthly ${resourceType} limit`
}
await notificationService.notify({
userId,
type: 'subscription_update',
title: alertType === 'limit_reached' ? 'Usage Limit Reached' : 'Usage Alert',
message: messages[alertType] || 'Usage alert',
data: { resourceType, threshold }
})
}
private async getFreePlan() {
const { data } = await this.supabase
.from('analyzer.subscription_plans')
.select(`
*,
usage_limits (*)
`)
.eq('code', 'free')
.single()
return data
}
}3. Usage Enforcement Middleware
// apps/analyzer-app/src/middleware/usage-limiter.ts
import { NextRequest, NextResponse } from 'next/server'
import { UsageTracker } from '@/packages/usage'
import { createClient } from '@mystoryflow/database/server'
export async function checkUsageLimit(
req: NextRequest,
resourceType: string,
amount: number = 1
) {
const supabase = getSupabaseBrowserClient()
// Get user from session
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const tracker = new UsageTracker()
const check = await tracker.checkUsage(user.id, resourceType, amount)
if (!check.allowed) {
return NextResponse.json(
{
error: 'Usage limit exceeded',
limit: check.limit,
used: check.currentUsage,
upgrade_url: '/pricing'
},
{ status: 429 }
)
}
return null // Continue with request
}
// Example usage in API route
export async function POST(req: NextRequest) {
// Check word count limit
const { content } = await req.json()
const wordCount = content.split(/\s+/).length
const limitError = await checkUsageLimit(req, 'word_count', wordCount)
if (limitError) return limitError
// Check analysis limit
const analysisLimitError = await checkUsageLimit(req, 'analysis', 1)
if (analysisLimitError) return analysisLimitError
// Continue with analysis...
}4. Usage Dashboard Component
// apps/analyzer-app/src/components/usage/UsageDashboard.tsx
'use client'
import { Card, Progress, Alert, Button } from '@mystoryflow/ui'
import { BarChart, LineChart } from '@mystoryflow/ui/charts'
import { AlertCircle, TrendingUp, FileText, MessageSquare } from 'lucide-react'
import { useUsage } from '@/hooks/useUsage'
export function UsageDashboard({ userId }: { userId: string }) {
const { summary, history, loading } = useUsage(userId)
if (loading) return <div>Loading usage data...</div>
return (
<div className="space-y-6">
{/* Usage Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<UsageCard
title="Analyses"
icon={<FileText />}
usage={summary.analysis}
color="blue"
/>
<UsageCard
title="Words Analyzed"
icon={<FileText />}
usage={summary.word_count}
color="green"
format="number"
/>
<UsageCard
title="Consultations"
icon={<MessageSquare />}
usage={summary.consultation}
color="purple"
/>
</div>
{/* Usage Alerts */}
{Object.entries(summary).map(([resource, data]: [string, any]) => {
if (data.percentage >= 80 && !data.unlimited) {
return (
<Alert key={resource} variant="warning">
<AlertCircle className="h-4 w-4" />
<div className="flex items-center justify-between w-full">
<span>
You've used {data.percentage}% of your {resource} limit this month
</span>
<Button size="sm" variant="outline" asChild>
<a href="/pricing">Upgrade Plan</a>
</Button>
</div>
</Alert>
)
}
return null
})}
{/* Usage History Chart */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Usage History</h3>
<div className="h-64">
<LineChart
data={history}
lines={[
{ key: 'analyses', color: '#3b82f6', name: 'Analyses' },
{ key: 'consultations', color: '#8b5cf6', name: 'Consultations' }
]}
xKey="date"
/>
</div>
</Card>
{/* Detailed Usage Table */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Recent Usage</h3>
<UsageTable userId={userId} />
</Card>
</div>
)
}
function UsageCard({
title,
icon,
usage,
color,
format = 'ratio'
}: {
title: string
icon: React.ReactNode
usage: any
color: string
format?: 'ratio' | 'number'
}) {
const percentage = usage.unlimited ? 0 : usage.percentage
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className={`p-2 bg-${color}-100 dark:bg-${color}-900 rounded-lg`}>
{icon}
</div>
{percentage >= 80 && !usage.unlimited && (
<Badge variant="warning">
{percentage}% used
</Badge>
)}
</div>
<h3 className="font-medium mb-2">{title}</h3>
{usage.unlimited ? (
<div>
<p className="text-2xl font-bold">Unlimited</p>
<p className="text-sm text-muted-foreground">
{usage.used.toLocaleString()} used this month
</p>
</div>
) : format === 'ratio' ? (
<div>
<p className="text-2xl font-bold">
{usage.used} / {usage.limit}
</p>
<Progress value={percentage} className="mt-2" />
</div>
) : (
<div>
<p className="text-2xl font-bold">
{usage.used.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">
of {usage.limit.toLocaleString()} limit
</p>
<Progress value={percentage} className="mt-2" />
</div>
)}
</Card>
)
}5. Usage Tracking Hook
// apps/analyzer-app/src/hooks/useUsage.ts
import { useEffect, useState } from 'react'
import { createClient } from '@mystoryflow/database/client'
export function useUsage(userId: string) {
const [summary, setSummary] = useState<any>({})
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const supabase = getSupabaseBrowserClient()
useEffect(() => {
if (userId) {
loadUsageData()
}
}, [userId])
const loadUsageData = async () => {
try {
// Get usage summary
const summaryResponse = await fetch('/api/usage/summary')
const summaryData = await summaryResponse.json()
setSummary(summaryData)
// Get usage history for charts
const { data: records } = await supabase
.from('analyzer.usage_records')
.select('*')
.eq('user_id', userId)
.gte('recorded_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString())
.order('recorded_at')
// Group by day
const grouped = records?.reduce((acc, record) => {
const date = new Date(record.recorded_at).toLocaleDateString()
if (!acc[date]) {
acc[date] = { date, analyses: 0, consultations: 0 }
}
if (record.resource_type === 'analysis') {
acc[date].analyses += record.quantity
} else if (record.resource_type === 'consultation') {
acc[date].consultations += record.quantity
}
return acc
}, {})
setHistory(Object.values(grouped || {}))
} catch (error) {
console.error('Error loading usage:', error)
} finally {
setLoading(false)
}
}
const checkLimit = async (resourceType: string, amount: number = 1): Promise<boolean> => {
try {
const response = await fetch('/api/usage/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceType, amount })
})
const result = await response.json()
return result.allowed
} catch (error) {
console.error('Error checking limit:', error)
return false
}
}
return {
summary,
history,
loading,
checkLimit,
refresh: loadUsageData
}
}6. Admin Usage Analytics
// apps/analyzer-app/src/app/admin/usage/page.tsx
import { Card } from '@mystoryflow/ui'
import { createClient } from '@mystoryflow/database/server'
import { BarChart, LineChart } from '@mystoryflow/ui/charts'
export default async function AdminUsagePage() {
const supabase = getSupabaseBrowserClient()
// Get overall usage statistics
const { data: stats } = await supabase.rpc('get_platform_usage_stats')
// Get usage by plan
const { data: planUsage } = await supabase.rpc('get_usage_by_plan')
// Get top users
const { data: topUsers } = await supabase
.from('analyzer.usage_aggregates')
.select(`
user_id,
users (email),
resource_type,
total_usage
`)
.gte('period_start', new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString())
.order('total_usage', { ascending: false })
.limit(10)
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Platform Usage Analytics</h1>
{/* Overall Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card className="p-6">
<h3 className="text-sm font-medium text-muted-foreground">
Total Analyses (MTD)
</h3>
<p className="text-2xl font-bold">{stats?.total_analyses || 0}</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-muted-foreground">
Words Analyzed (MTD)
</h3>
<p className="text-2xl font-bold">
{(stats?.total_words || 0).toLocaleString()}
</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-muted-foreground">
Active Users
</h3>
<p className="text-2xl font-bold">{stats?.active_users || 0}</p>
</Card>
<Card className="p-6">
<h3 className="text-sm font-medium text-muted-foreground">
Limit Violations
</h3>
<p className="text-2xl font-bold">{stats?.limit_violations || 0}</p>
</Card>
</div>
{/* Usage by Plan */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Usage by Plan</h2>
<div className="h-64">
<BarChart
data={planUsage}
xKey="plan_name"
bars={[
{ key: 'analyses', color: '#3b82f6', name: 'Analyses' },
{ key: 'consultations', color: '#8b5cf6', name: 'Consultations' }
]}
/>
</div>
</Card>
{/* Top Users */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Top Users This Month</h2>
<div className="space-y-2">
{topUsers?.map((user, i) => (
<div key={i} className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<span className="font-medium">{user.users.email}</span>
<div className="text-sm text-muted-foreground">
{user.resource_type}: {user.total_usage}
</div>
</div>
))}
</div>
</Card>
</div>
)
}MVP Acceptance Criteria
- Real-time usage tracking
- Limit enforcement for all resources
- Usage visualization dashboard
- Alert system for approaching limits
- Monthly usage reset
- API middleware for limit checking
- Admin usage analytics
- Overage prevention
Post-MVP Enhancements
- Rollover unused credits
- Bonus credits system
- Usage forecasting
- Custom alert thresholds
- Usage export/reports
- Team usage pooling
- Pay-per-use options
- Usage optimization tips
Implementation Time
- Development: 2 days
- Testing: 0.5 days
- Total: 2.5 days
Dependencies
- F021-SUBSCRIPTION-TIERS (plan limits)
- Background job scheduler for resets
- Real-time aggregation system
Admin Dashboard Integration
Enhanced Admin Integration
The usage tracking system integrates deeply with the MyStoryFlow admin dashboard:
1. Admin Service Extensions
// packages/admin/src/lib/analyzer-admin-service.ts
import { adminService } from '@mystoryflow/admin'
export const analyzerAdminService = {
...adminService,
// Analyzer-specific metrics
async getAnalyzerMetrics(dateRange?: DateRange) {
const supabase = createClient()
const { data: metrics } = await supabase
.rpc('get_analyzer_platform_metrics', {
start_date: dateRange?.from,
end_date: dateRange?.to
})
return {
overview: metrics.overview,
aiUsage: metrics.ai_usage,
costAnalysis: metrics.cost_analysis,
userBehavior: metrics.user_behavior,
performanceMetrics: metrics.performance
}
},
// Real-time usage monitoring
async monitorActiveAnalyses() {
const supabase = createClient()
return supabase
.channel('analyzer-usage')
.on('postgres_changes', {
event: '*',
schema: 'analyzer',
table: 'usage_records'
}, (payload) => {
// Update admin dashboard in real-time
this.broadcastUsageUpdate(payload)
})
.subscribe()
}
}2. Admin Dashboard Components
// apps/web/src/app/(admin)/admin/analyzer/components/UsageMonitor.tsx
import { Card, Badge, Progress } from '@mystoryflow/admin'
import { useRealTimeUsage } from '@/hooks/useRealTimeUsage'
export function AnalyzerUsageMonitor() {
const { activeAnalyses, resourceUsage, alerts } = useRealTimeUsage()
return (
<div className="grid gap-4">
{/* Active Analyses */}
<Card>
<CardHeader>
<CardTitle>Active Analyses</CardTitle>
<Badge>{activeAnalyses.length} running</Badge>
</CardHeader>
<CardContent>
{activeAnalyses.map(analysis => (
<div key={analysis.id} className="flex items-center gap-4 py-2">
<Progress value={analysis.progress} className="flex-1" />
<span className="text-sm">{analysis.user}</span>
<Badge variant={analysis.status}>{analysis.status}</Badge>
</div>
))}
</CardContent>
</Card>
{/* Resource Usage */}
<Card>
<CardHeader>
<CardTitle>Resource Usage (Last Hour)</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<ResourceMeter
label="API Calls"
current={resourceUsage.apiCalls}
limit={1000}
/>
<ResourceMeter
label="AI Tokens"
current={resourceUsage.tokens}
limit={1000000}
/>
<ResourceMeter
label="Storage (GB)"
current={resourceUsage.storage}
limit={100}
/>
</div>
</CardContent>
</Card>
{/* Usage Alerts */}
{alerts.length > 0 && (
<Alert variant="warning">
<AlertTitle>Usage Alerts</AlertTitle>
<AlertDescription>
<ul className="space-y-1">
{alerts.map((alert, i) => (
<li key={i}>{alert.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</div>
)
}3. Admin Routes Configuration
// apps/web/src/app/(admin)/admin/layout.tsx
import { AdminSidebar } from '@mystoryflow/admin'
const analyzerMenuItems = [
{
title: 'Analyzer Dashboard',
href: '/admin/analyzer',
icon: 'chart-bar'
},
{
title: 'Usage Analytics',
href: '/admin/analyzer/usage',
icon: 'trending-up'
},
{
title: 'AI Models',
href: '/admin/analyzer/ai-models',
icon: 'cpu'
},
{
title: 'Manuscripts',
href: '/admin/analyzer/manuscripts',
icon: 'file-text'
},
{
title: 'Reference Data',
href: '/admin/analyzer/reference-data',
icon: 'database'
}
]4. Reference Data Management
// apps/web/src/app/(admin)/admin/analyzer/reference-data/page.tsx
import { ReferenceDataManager } from '@mystoryflow/admin'
import { analyzerCollections } from '@/lib/reference-data/collections'
export default function ReferenceDataPage() {
return (
<ReferenceDataManager
collections={[
'manuscript-genres',
'analysis-statuses',
'consultation-types',
'coach-specialties',
'scoring-categories',
'manuscript-types'
]}
onSave={async (collection, data) => {
// Save to reference data system
await updateReferenceData(collection, data)
// Invalidate caches
await revalidatePath('/api/reference-data')
}}
/>
)
}Next Feature
After completion, proceed to F023-CONSULTATION-SYSTEM for coach bookings.