feat: implement comprehensive credits management system
- Database schema: * Extended user_credits table with daily_limit, API costs, last_reset_at * Created credit_transactions table for full audit trail * Added RLS policies for secure access control - Core services: * credit-service.ts - balance checking, deduction, addition, transaction history * admin-credit-service.ts - admin controls for managing users and credits - API integration: * Integrated credit checking into discover-whiskybase.ts * Credits deducted after successful API calls * Insufficient credits error handling - Admin interface: * /admin/users page with user management * Statistics dashboard (total users, credits in circulation, usage) * Interactive user table with search * Edit modal for credit adjustment and settings * Per-user daily limits and API cost configuration - Features: * Automatic credit initialization (100 credits for new users) * Credit transaction logging with balance_after tracking * Admin can add/remove credits with reason * Admin can set custom daily limits per user * Admin can set custom API costs per user * Low credit warnings (< 10 credits) * Full transaction history - User experience: * Credits checked before API calls * Clear error messages for insufficient credits * Graceful handling of credit deduction failures System is ready for future enhancements like credit packages, auto-recharge, and payment integration.
This commit is contained in:
236
src/services/admin-credit-service.ts
Normal file
236
src/services/admin-credit-service.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
'use server';
|
||||
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { checkIsAdmin } from './track-api-usage';
|
||||
import { addCredits, getUserCredits } from './credit-service';
|
||||
|
||||
interface UserWithCredits {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
balance: number;
|
||||
total_purchased: number;
|
||||
total_used: number;
|
||||
daily_limit: number | null;
|
||||
google_search_cost: number;
|
||||
gemini_ai_cost: number;
|
||||
last_active?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users with their credit information (admin only)
|
||||
*/
|
||||
export async function getAllUsersWithCredits(): Promise<UserWithCredits[]> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return [];
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return [];
|
||||
|
||||
// Get all users with their profiles
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username');
|
||||
|
||||
if (profilesError) {
|
||||
console.error('Error fetching profiles:', profilesError);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all user credits
|
||||
const { data: credits, error: creditsError } = await supabase
|
||||
.from('user_credits')
|
||||
.select('*');
|
||||
|
||||
if (creditsError) {
|
||||
console.error('Error fetching credits:', creditsError);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get user emails from auth.users
|
||||
const { data: { users }, error: usersError } = await supabase.auth.admin.listUsers();
|
||||
|
||||
if (usersError) {
|
||||
console.error('Error fetching users:', usersError);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Combine data
|
||||
const usersWithCredits: UserWithCredits[] = profiles?.map(profile => {
|
||||
const userAuth = users.find(u => u.id === profile.id);
|
||||
const userCredits = credits?.find(c => c.user_id === profile.id);
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
email: userAuth?.email || 'Unknown',
|
||||
username: profile.username || 'Unknown',
|
||||
balance: userCredits?.balance || 0,
|
||||
total_purchased: userCredits?.total_purchased || 0,
|
||||
total_used: userCredits?.total_used || 0,
|
||||
daily_limit: userCredits?.daily_limit || null,
|
||||
google_search_cost: userCredits?.google_search_cost || 1,
|
||||
gemini_ai_cost: userCredits?.gemini_ai_cost || 1,
|
||||
last_active: userAuth?.last_sign_in_at || undefined
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return usersWithCredits;
|
||||
} catch (err) {
|
||||
console.error('Error in getAllUsersWithCredits:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's credit balance (admin only)
|
||||
*/
|
||||
export async function updateUserCredits(
|
||||
userId: string,
|
||||
newBalance: number,
|
||||
reason: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
// Get current credits
|
||||
const currentCredits = await getUserCredits(userId);
|
||||
if (!currentCredits) {
|
||||
return { success: false, error: 'User credits not found' };
|
||||
}
|
||||
|
||||
const difference = newBalance - currentCredits.balance;
|
||||
|
||||
// Use addCredits which handles the transaction logging
|
||||
const result = await addCredits(userId, difference, reason, user.id);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error in updateUserCredits:', err);
|
||||
return { success: false, error: 'Failed to update credits' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's daily limit (admin only)
|
||||
*/
|
||||
export async function setUserDailyLimit(
|
||||
userId: string,
|
||||
dailyLimit: number | null
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_credits')
|
||||
.update({ daily_limit: dailyLimit })
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting daily limit:', error);
|
||||
return { success: false, error: 'Failed to set daily limit' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in setUserDailyLimit:', err);
|
||||
return { success: false, error: 'Failed to set daily limit' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's API costs (admin only)
|
||||
*/
|
||||
export async function setUserApiCosts(
|
||||
userId: string,
|
||||
googleSearchCost: number,
|
||||
geminiAiCost: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, error: 'Not authorized' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_credits')
|
||||
.update({
|
||||
google_search_cost: googleSearchCost,
|
||||
gemini_ai_cost: geminiAiCost
|
||||
})
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting API costs:', error);
|
||||
return { success: false, error: 'Failed to set API costs' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in setUserApiCosts:', err);
|
||||
return { success: false, error: 'Failed to set API costs' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk add credits to multiple users (admin only)
|
||||
*/
|
||||
export async function bulkAddCredits(
|
||||
userIds: string[],
|
||||
amount: number,
|
||||
reason: string
|
||||
): Promise<{ success: boolean; processed: number; failed: number; error?: string }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
// Check if current user is admin
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, processed: 0, failed: 0, error: 'Not authenticated' };
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) return { success: false, processed: 0, failed: 0, error: 'Not authorized' };
|
||||
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
const result = await addCredits(userId, amount, reason, user.id);
|
||||
if (result.success) {
|
||||
processed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processed,
|
||||
failed
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error in bulkAddCredits:', err);
|
||||
return { success: false, processed: 0, failed: 0, error: 'Failed to bulk add credits' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user