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:
2025-12-18 15:02:32 +01:00
parent e960d1bace
commit 95a8b3940b
7 changed files with 1013 additions and 6 deletions

View 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' };
}
}

View File

@@ -0,0 +1,273 @@
'use server';
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
interface UserCredits {
user_id: string;
balance: number;
total_purchased: number;
total_used: number;
daily_limit: number | null;
google_search_cost: number;
gemini_ai_cost: number;
last_reset_at: string;
updated_at: string;
}
interface CreditTransaction {
id: string;
user_id: string;
amount: number;
type: 'deduction' | 'addition' | 'admin_adjustment';
reason?: string;
api_type?: 'google_search' | 'gemini_ai';
admin_id?: string;
balance_after: number;
created_at: string;
}
/**
* Get user's credit information
*/
export async function getUserCredits(userId: string): Promise<UserCredits | null> {
try {
const supabase = createServerComponentClient({ cookies });
const { data, error } = await supabase
.from('user_credits')
.select('*')
.eq('user_id', userId)
.single();
if (error) {
// If user doesn't have credits yet, create entry with default values
if (error.code === 'PGRST116') {
const { data: newCredits, error: insertError } = await supabase
.from('user_credits')
.insert({
user_id: userId,
balance: 100, // Starting credits
total_purchased: 100,
total_used: 0
})
.select()
.single();
if (insertError) {
console.error('Error creating user credits:', insertError);
return null;
}
return newCredits;
}
console.error('Error fetching user credits:', error);
return null;
}
return data;
} catch (err) {
console.error('Error in getUserCredits:', err);
return null;
}
}
/**
* Check if user has enough credits
*/
export async function checkCreditBalance(
userId: string,
apiType: 'google_search' | 'gemini_ai'
): Promise<{ allowed: boolean; balance: number; cost: number; message?: string }> {
try {
const credits = await getUserCredits(userId);
if (!credits) {
return {
allowed: false,
balance: 0,
cost: 1,
message: 'Could not fetch credit information'
};
}
const cost = apiType === 'google_search'
? credits.google_search_cost
: credits.gemini_ai_cost;
const allowed = credits.balance >= cost;
return {
allowed,
balance: credits.balance,
cost,
message: allowed ? undefined : `Insufficient credits. You need ${cost} credits but have ${credits.balance}.`
};
} catch (err) {
console.error('Error in checkCreditBalance:', err);
return {
allowed: false,
balance: 0,
cost: 1,
message: 'Error checking credit balance'
};
}
}
/**
* Deduct credits from user's balance
*/
export async function deductCredits(
userId: string,
apiType: 'google_search' | 'gemini_ai',
reason?: string
): Promise<{ success: boolean; newBalance?: number; error?: string }> {
try {
const supabase = createServerComponentClient({ cookies });
// Get current credits
const credits = await getUserCredits(userId);
if (!credits) {
return { success: false, error: 'Could not fetch credit information' };
}
const cost = apiType === 'google_search'
? credits.google_search_cost
: credits.gemini_ai_cost;
// Check if user has enough credits
if (credits.balance < cost) {
return {
success: false,
error: `Insufficient credits. Need ${cost}, have ${credits.balance}`
};
}
const newBalance = credits.balance - cost;
// Update user credits
const { error: updateError } = await supabase
.from('user_credits')
.update({
balance: newBalance,
total_used: credits.total_used + cost,
updated_at: new Date().toISOString()
})
.eq('user_id', userId);
if (updateError) {
console.error('Error updating user credits:', updateError);
return { success: false, error: 'Failed to update credits' };
}
// Log transaction
const { error: transactionError } = await supabase
.from('credit_transactions')
.insert({
user_id: userId,
amount: -cost,
type: 'deduction',
reason: reason || `${apiType} API call`,
api_type: apiType,
balance_after: newBalance
});
if (transactionError) {
console.error('Error logging credit transaction:', transactionError);
// Don't fail the operation if logging fails
}
return { success: true, newBalance };
} catch (err) {
console.error('Error in deductCredits:', err);
return { success: false, error: 'Failed to deduct credits' };
}
}
/**
* Add credits to user's balance
*/
export async function addCredits(
userId: string,
amount: number,
reason: string,
adminId?: string
): Promise<{ success: boolean; newBalance?: number; error?: string }> {
try {
const supabase = createServerComponentClient({ cookies });
// Get current credits
const credits = await getUserCredits(userId);
if (!credits) {
return { success: false, error: 'Could not fetch credit information' };
}
const newBalance = credits.balance + amount;
// Update user credits
const { error: updateError } = await supabase
.from('user_credits')
.update({
balance: newBalance,
total_purchased: adminId ? credits.total_purchased + amount : credits.total_purchased,
updated_at: new Date().toISOString()
})
.eq('user_id', userId);
if (updateError) {
console.error('Error updating user credits:', updateError);
return { success: false, error: 'Failed to update credits' };
}
// Log transaction
const { error: transactionError } = await supabase
.from('credit_transactions')
.insert({
user_id: userId,
amount: amount,
type: adminId ? 'admin_adjustment' : 'addition',
reason: reason,
admin_id: adminId,
balance_after: newBalance
});
if (transactionError) {
console.error('Error logging credit transaction:', transactionError);
}
return { success: true, newBalance };
} catch (err) {
console.error('Error in addCredits:', err);
return { success: false, error: 'Failed to add credits' };
}
}
/**
* Get user's credit transaction history
*/
export async function getCreditTransactions(
userId: string,
limit: number = 50
): Promise<CreditTransaction[]> {
try {
const supabase = createServerComponentClient({ cookies });
const { data, error } = await supabase
.from('credit_transactions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit);
if (error) {
console.error('Error fetching credit transactions:', error);
return [];
}
return data || [];
} catch (err) {
console.error('Error in getCreditTransactions:', err);
return [];
}
}

View File

@@ -1,6 +1,7 @@
'use server';
import { trackApiUsage, checkDailyLimit } from './track-api-usage';
import { checkCreditBalance, deductCredits } from './credit-service';
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
@@ -49,6 +50,16 @@ export async function discoverWhiskybaseId(bottle: {
};
}
// Check credit balance before making API call
const creditCheck = await checkCreditBalance(user.id, 'google_search');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Du benötigst ${creditCheck.cost} Credits, hast aber nur ${creditCheck.balance}.`,
insufficientCredits: true
};
}
try {
// Construct targeted search query
const queryParts = [
@@ -90,6 +101,13 @@ export async function discoverWhiskybaseId(bottle: {
success: true
});
// Deduct credits after successful API call
const creditDeduction = await deductCredits(user.id, 'google_search', 'Whiskybase search');
if (!creditDeduction.success) {
console.error('Failed to deduct credits:', creditDeduction.error);
// Don't fail the search if credit deduction fails
}
if (!data.items || data.items.length === 0) {
return {
success: false,