feat: implement API usage tracking and admin dashboard
- Added database schema for API tracking system: * api_usage table - tracks all Google Search and Gemini AI calls * user_credits table - prepared for future credits system * admin_users table - controls admin dashboard access - Created comprehensive tracking service (track-api-usage.ts): * trackApiUsage() - records API calls with success/failure * checkDailyLimit() - enforces 80 Google Search calls/day limit * getUserApiStats() - per-user statistics * getGlobalApiStats() - app-wide statistics (admin only) * checkIsAdmin() - server-side authorization - Integrated tracking into discover-whiskybase.ts: * Pre-call limit checking with friendly error messages * Post-call usage tracking for success and failures * User authentication verification - Built admin dashboard at /admin: * Global statistics cards (total, today, by API type) * Top 10 users by API usage * Recent activity log with 50 latest calls * Color-coded status indicators * Secure access with RLS policies - Features: * Daily limit resets at midnight Europe/Berlin timezone * Graceful error handling (allows on tracking failure) * Comprehensive indexes for fast queries * Ready for future credits/monetization system
This commit is contained in:
209
src/services/track-api-usage.ts
Normal file
209
src/services/track-api-usage.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
'use server';
|
||||
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
interface TrackApiUsageParams {
|
||||
userId: string;
|
||||
apiType: 'google_search' | 'gemini_ai';
|
||||
endpoint?: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface ApiStats {
|
||||
totalCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
todayCalls: number;
|
||||
googleSearchCalls: number;
|
||||
geminiAiCalls: number;
|
||||
}
|
||||
|
||||
interface DailyLimitCheck {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
const GOOGLE_SEARCH_DAILY_LIMIT = 80;
|
||||
|
||||
/**
|
||||
* Track an API usage event
|
||||
*/
|
||||
export async function trackApiUsage(params: TrackApiUsageParams): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { error } = await supabase
|
||||
.from('api_usage')
|
||||
.insert({
|
||||
user_id: params.userId,
|
||||
api_type: params.apiType,
|
||||
endpoint: params.endpoint,
|
||||
success: params.success,
|
||||
error_message: params.errorMessage,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to track API usage:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error tracking API usage:', err);
|
||||
return { success: false, error: 'Failed to track API usage' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daily limit has been reached for a specific API type
|
||||
*/
|
||||
export async function checkDailyLimit(apiType: 'google_search' | 'gemini_ai'): Promise<DailyLimitCheck> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Only enforce limit for Google Search
|
||||
if (apiType !== 'google_search') {
|
||||
return { allowed: true, remaining: 999999, limit: 999999 };
|
||||
}
|
||||
|
||||
// Get today's date in Europe/Berlin timezone
|
||||
const today = new Date();
|
||||
const berlinDate = new Date(today.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
|
||||
const startOfDay = new Date(berlinDate);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(berlinDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const { count, error } = await supabase
|
||||
.from('api_usage')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('api_type', apiType)
|
||||
.gte('created_at', startOfDay.toISOString())
|
||||
.lte('created_at', endOfDay.toISOString());
|
||||
|
||||
if (error) {
|
||||
console.error('Error checking daily limit:', error);
|
||||
// Allow on error to avoid blocking users
|
||||
return { allowed: true, remaining: GOOGLE_SEARCH_DAILY_LIMIT, limit: GOOGLE_SEARCH_DAILY_LIMIT };
|
||||
}
|
||||
|
||||
const currentCount = count || 0;
|
||||
const remaining = Math.max(0, GOOGLE_SEARCH_DAILY_LIMIT - currentCount);
|
||||
const allowed = currentCount < GOOGLE_SEARCH_DAILY_LIMIT;
|
||||
|
||||
return { allowed, remaining, limit: GOOGLE_SEARCH_DAILY_LIMIT };
|
||||
} catch (err) {
|
||||
console.error('Error in checkDailyLimit:', err);
|
||||
// Allow on error
|
||||
return { allowed: true, remaining: GOOGLE_SEARCH_DAILY_LIMIT, limit: GOOGLE_SEARCH_DAILY_LIMIT };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API usage statistics for a specific user
|
||||
*/
|
||||
export async function getUserApiStats(userId: string): Promise<ApiStats | null> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('api_usage')
|
||||
.select('*')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching user API stats:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const berlinDate = new Date(today.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
|
||||
const startOfDay = new Date(berlinDate);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayData = data.filter(item => new Date(item.created_at) >= startOfDay);
|
||||
|
||||
return {
|
||||
totalCalls: data.length,
|
||||
successfulCalls: data.filter(item => item.success).length,
|
||||
failedCalls: data.filter(item => !item.success).length,
|
||||
todayCalls: todayData.length,
|
||||
googleSearchCalls: data.filter(item => item.api_type === 'google_search').length,
|
||||
geminiAiCalls: data.filter(item => item.api_type === 'gemini_ai').length,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error in getUserApiStats:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global API usage statistics (admin only)
|
||||
*/
|
||||
export async function getGlobalApiStats(): Promise<ApiStats | null> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return null;
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('api_usage')
|
||||
.select('*');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching global API stats:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const berlinDate = new Date(today.toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
|
||||
const startOfDay = new Date(berlinDate);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayData = data.filter(item => new Date(item.created_at) >= startOfDay);
|
||||
|
||||
return {
|
||||
totalCalls: data.length,
|
||||
successfulCalls: data.filter(item => item.success).length,
|
||||
failedCalls: data.filter(item => !item.success).length,
|
||||
todayCalls: todayData.length,
|
||||
googleSearchCalls: data.filter(item => item.api_type === 'google_search').length,
|
||||
geminiAiCalls: data.filter(item => item.api_type === 'gemini_ai').length,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error in getGlobalApiStats:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an admin
|
||||
*/
|
||||
export async function checkIsAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!data;
|
||||
} catch (err) {
|
||||
console.error('Error checking admin status:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user