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:
@@ -82,12 +82,20 @@ export default async function AdminPage() {
|
||||
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Back to App
|
||||
</Link>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"
|
||||
>
|
||||
Manage Users
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Back to App
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Stats Cards */}
|
||||
|
||||
98
src/app/admin/users/page.tsx
Normal file
98
src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||
import { getAllUsersWithCredits } from '@/services/admin-credit-service';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, Users, Coins, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import UserManagementClient from '@/components/UserManagementClient';
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const isAdmin = await checkIsAdmin(user.id);
|
||||
if (!isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
// Fetch all users with credits
|
||||
const users = await getAllUsersWithCredits();
|
||||
|
||||
// Calculate statistics
|
||||
const totalUsers = users.length;
|
||||
const totalCreditsInCirculation = users.reduce((sum, u) => sum + u.balance, 0);
|
||||
const totalCreditsPurchased = users.reduce((sum, u) => sum + u.total_purchased, 0);
|
||||
const totalCreditsUsed = users.reduce((sum, u) => sum + u.total_used, 0);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
|
||||
<div className="max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 text-zinc-500 hover:text-amber-600 transition-colors font-medium mb-2"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">User Management</h1>
|
||||
<p className="text-zinc-500 mt-1">Manage user credits and limits</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<Users size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Total Users</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<Coins size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Credits in Circulation</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Total Purchased</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<TrendingDown size={20} className="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase text-zinc-400">Total Used</span>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsUsed.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Management Table */}
|
||||
<UserManagementClient initialUsers={users} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
318
src/components/UserManagementClient.tsx
Normal file
318
src/components/UserManagementClient.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Edit, Plus, Search, X, Check, AlertCircle } from 'lucide-react';
|
||||
import { updateUserCredits, setUserDailyLimit, setUserApiCosts } from '@/services/admin-credit-service';
|
||||
|
||||
interface User {
|
||||
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;
|
||||
}
|
||||
|
||||
interface UserManagementClientProps {
|
||||
initialUsers: User[];
|
||||
}
|
||||
|
||||
export default function UserManagementClient({ initialUsers }: UserManagementClientProps) {
|
||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [creditAmount, setCreditAmount] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [dailyLimit, setDailyLimit] = useState('');
|
||||
const [googleCost, setGoogleCost] = useState('');
|
||||
const [geminiCost, setGeminiCost] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.username.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setCreditAmount('');
|
||||
setReason('');
|
||||
setDailyLimit(user.daily_limit?.toString() || '');
|
||||
setGoogleCost(user.google_search_cost.toString());
|
||||
setGeminiCost(user.gemini_ai_cost.toString());
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const handleUpdateCredits = async () => {
|
||||
if (!editingUser || !creditAmount || !reason) {
|
||||
setMessage({ type: 'error', text: 'Please fill in all fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
const amount = parseInt(creditAmount);
|
||||
const newBalance = editingUser.balance + amount;
|
||||
|
||||
const result = await updateUserCredits(editingUser.id, newBalance, reason);
|
||||
|
||||
if (result.success) {
|
||||
// Update local state
|
||||
setUsers(users.map(u =>
|
||||
u.id === editingUser.id
|
||||
? { ...u, balance: newBalance, total_purchased: u.total_purchased + (amount > 0 ? amount : 0) }
|
||||
: u
|
||||
));
|
||||
setMessage({ type: 'success', text: `Successfully ${amount > 0 ? 'added' : 'removed'} ${Math.abs(amount)} credits` });
|
||||
setCreditAmount('');
|
||||
setReason('');
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to update credits' });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleUpdateSettings = async () => {
|
||||
if (!editingUser) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
// Update daily limit
|
||||
if (dailyLimit !== (editingUser.daily_limit?.toString() || '')) {
|
||||
const limitValue = dailyLimit === '' ? null : parseInt(dailyLimit);
|
||||
const limitResult = await setUserDailyLimit(editingUser.id, limitValue);
|
||||
if (!limitResult.success) {
|
||||
setMessage({ type: 'error', text: 'Failed to update daily limit' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update API costs
|
||||
if (googleCost !== editingUser.google_search_cost.toString() || geminiCost !== editingUser.gemini_ai_cost.toString()) {
|
||||
const costsResult = await setUserApiCosts(
|
||||
editingUser.id,
|
||||
parseInt(googleCost),
|
||||
parseInt(geminiCost)
|
||||
);
|
||||
if (!costsResult.success) {
|
||||
setMessage({ type: 'error', text: 'Failed to update API costs' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(u =>
|
||||
u.id === editingUser.id
|
||||
? {
|
||||
...u,
|
||||
daily_limit: dailyLimit === '' ? null : parseInt(dailyLimit),
|
||||
google_search_cost: parseInt(googleCost),
|
||||
gemini_ai_cost: parseInt(geminiCost)
|
||||
}
|
||||
: u
|
||||
));
|
||||
|
||||
setMessage({ type: 'success', text: 'Settings updated successfully' });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by email or username..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Table */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Users ({filteredUsers.length})</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-zinc-800">
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">User</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Balance</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Used</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Daily Limit</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-black uppercase text-zinc-400">Costs</th>
|
||||
<th className="text-right py-3 px-4 text-xs font-black uppercase text-zinc-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="border-b border-zinc-100 dark:border-zinc-800/50 hover:bg-zinc-50 dark:hover:bg-zinc-800/30">
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white">{user.username}</div>
|
||||
<div className="text-xs text-zinc-500">{user.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-lg font-black ${user.balance < 10 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{user.balance}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{user.total_used}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{user.daily_limit || 'Global (80)'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-zinc-500">
|
||||
G:{user.google_search_cost} / AI:{user.gemini_ai_cost}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-bold transition-colors"
|
||||
>
|
||||
<Edit size={14} />
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black text-zinc-900 dark:text-white">Edit User</h3>
|
||||
<p className="text-sm text-zinc-500 mt-1">{editingUser.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-xl flex items-center gap-3 ${message.type === 'success'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-500'
|
||||
}`}>
|
||||
{message.type === 'success' ? <Check size={20} /> : <AlertCircle size={20} />}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Current Balance */}
|
||||
<div className="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-xl">
|
||||
<div className="text-xs font-black uppercase text-zinc-400 mb-1">Current Balance</div>
|
||||
<div className="text-3xl font-black text-zinc-900 dark:text-white">{editingUser.balance} Credits</div>
|
||||
</div>
|
||||
|
||||
{/* Add/Remove Credits */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold text-zinc-900 dark:text-white">Adjust Credits</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
value={creditAmount}
|
||||
onChange={(e) => setCreditAmount(e.target.value)}
|
||||
placeholder="e.g. 100 or -50"
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. Monthly bonus"
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdateCredits}
|
||||
disabled={loading || !creditAmount || !reason}
|
||||
className="w-full py-3 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Update Credits
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="border-zinc-200 dark:border-zinc-800" />
|
||||
|
||||
{/* Settings */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold text-zinc-900 dark:text-white">User Settings</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Daily Limit</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dailyLimit}
|
||||
onChange={(e) => setDailyLimit(e.target.value)}
|
||||
placeholder="Global (80)"
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Google Cost</label>
|
||||
<input
|
||||
type="number"
|
||||
value={googleCost}
|
||||
onChange={(e) => setGoogleCost(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Gemini Cost</label>
|
||||
<input
|
||||
type="number"
|
||||
value={geminiCost}
|
||||
onChange={(e) => setGeminiCost(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdateSettings}
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-bold rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check size={18} />
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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' };
|
||||
}
|
||||
}
|
||||
273
src/services/credit-service.ts
Normal file
273
src/services/credit-service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user