feat: implement subscription plan system with monthly credits
- Database schema: * subscription_plans table - stores plan tiers (Starter, Bronze, Silver, Gold) * user_subscriptions table - assigns users to plans * Default plans created (10, 50, 100, 250 credits/month) * All existing users assigned to Starter plan - Subscription service (subscription-service.ts): * getAllPlans() - fetch all plans * getActivePlans() - fetch active plans for users * createPlan() - admin creates new plan * updatePlan() - admin edits plan * deletePlan() - admin removes plan * getUserSubscription() - get user's current plan * setUserPlan() - admin assigns user to plan * grantMonthlyCredits() - distribute credits to all users - Plan management interface (/admin/plans): * Visual plan cards with credits, price, description * Create/Edit/Delete plans * Toggle active/inactive status * Sort order management * Grant monthly credits button (manual trigger) - Features: * Monthly credit allocation based on plan * Prevents duplicate credit grants (tracks last_credit_grant_at) * Admin can manually trigger monthly credit distribution * Plans can be activated/deactivated * Custom pricing and credit amounts per plan - UI: * Beautiful plan cards with color coding * Modal for create/edit with validation * Success/error messages * Manage Plans button in admin dashboard Ready for future automation (cron job for monthly credits) and payment integration (Stripe/PayPal).
This commit is contained in:
@@ -83,6 +83,12 @@ export default async function AdminPage() {
|
||||
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/admin/plans"
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
||||
>
|
||||
Manage Plans
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-bold transition-colors"
|
||||
|
||||
49
src/app/admin/plans/page.tsx
Normal file
49
src/app/admin/plans/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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 { getAllPlans } from '@/services/subscription-service';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, Package } from 'lucide-react';
|
||||
import PlanManagementClient from '@/components/PlanManagementClient';
|
||||
|
||||
export default async function AdminPlansPage() {
|
||||
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 plans
|
||||
const plans = await getAllPlans();
|
||||
|
||||
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">Subscription Plans</h1>
|
||||
<p className="text-zinc-500 mt-1">Manage subscription tiers and monthly credits</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Management */}
|
||||
<PlanManagementClient initialPlans={plans} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
321
src/components/PlanManagementClient.tsx
Normal file
321
src/components/PlanManagementClient.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Edit, Trash2, X, Check, AlertCircle, Zap } from 'lucide-react';
|
||||
import { createPlan, updatePlan, deletePlan, grantMonthlyCredits, type SubscriptionPlan } from '@/services/subscription-service';
|
||||
|
||||
interface PlanManagementClientProps {
|
||||
initialPlans: SubscriptionPlan[];
|
||||
}
|
||||
|
||||
export default function PlanManagementClient({ initialPlans }: PlanManagementClientProps) {
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>(initialPlans);
|
||||
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
display_name: '',
|
||||
monthly_credits: 0,
|
||||
price: 0,
|
||||
description: '',
|
||||
is_active: true,
|
||||
sort_order: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const handleEdit = (plan: SubscriptionPlan) => {
|
||||
setEditingPlan(plan);
|
||||
setFormData({
|
||||
name: plan.name,
|
||||
display_name: plan.display_name,
|
||||
monthly_credits: plan.monthly_credits,
|
||||
price: plan.price,
|
||||
description: plan.description || '',
|
||||
is_active: plan.is_active,
|
||||
sort_order: plan.sort_order
|
||||
});
|
||||
setIsCreating(false);
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingPlan(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
display_name: '',
|
||||
monthly_credits: 0,
|
||||
price: 0,
|
||||
description: '',
|
||||
is_active: true,
|
||||
sort_order: plans.length + 1
|
||||
});
|
||||
setIsCreating(true);
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name || !formData.display_name || formData.monthly_credits <= 0) {
|
||||
setMessage({ type: 'error', text: 'Please fill in all required fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
if (isCreating) {
|
||||
const result = await createPlan(formData);
|
||||
if (result.success && result.plan) {
|
||||
setPlans([...plans, result.plan]);
|
||||
setMessage({ type: 'success', text: 'Plan created successfully' });
|
||||
setIsCreating(false);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to create plan' });
|
||||
}
|
||||
} else if (editingPlan) {
|
||||
const result = await updatePlan(editingPlan.id, formData);
|
||||
if (result.success) {
|
||||
setPlans(plans.map(p => p.id === editingPlan.id ? { ...p, ...formData } : p));
|
||||
setMessage({ type: 'success', text: 'Plan updated successfully' });
|
||||
setEditingPlan(null);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to update plan' });
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (planId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this plan? Users on this plan will be unassigned.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const result = await deletePlan(planId);
|
||||
|
||||
if (result.success) {
|
||||
setPlans(plans.filter(p => p.id !== planId));
|
||||
setMessage({ type: 'success', text: 'Plan deleted successfully' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to delete plan' });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleGrantCredits = async () => {
|
||||
if (!confirm('Grant monthly credits to all users based on their subscription plan?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
const result = await grantMonthlyCredits();
|
||||
|
||||
if (result.success) {
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: `Credits granted! Processed: ${result.processed}, Failed: ${result.failed}`
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to grant credits' });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Actions 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="flex gap-3">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex-1 py-3 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Create New Plan
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGrantCredits}
|
||||
disabled={loading}
|
||||
className="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Zap size={18} />
|
||||
Grant Monthly Credits
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`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>
|
||||
)}
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`bg-white dark:bg-zinc-900 rounded-2xl p-6 border-2 ${plan.is_active
|
||||
? 'border-amber-200 dark:border-amber-800'
|
||||
: 'border-zinc-200 dark:border-zinc-800 opacity-60'
|
||||
} shadow-sm relative`}
|
||||
>
|
||||
{!plan.is_active && (
|
||||
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 text-xs font-bold rounded">
|
||||
Inactive
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-2xl font-black text-zinc-900 dark:text-white">{plan.display_name}</h3>
|
||||
<p className="text-xs text-zinc-500 mt-1">{plan.name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="text-3xl font-black text-amber-600">{plan.monthly_credits}</div>
|
||||
<div className="text-xs text-zinc-500">Credits/Month</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="text-2xl font-bold text-zinc-900 dark:text-white">€{plan.price.toFixed(2)}</div>
|
||||
<div className="text-xs text-zinc-500">per month</div>
|
||||
</div>
|
||||
{plan.description && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-4">{plan.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(plan)}
|
||||
className="flex-1 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 font-bold rounded-lg text-sm transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit size={14} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id)}
|
||||
className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Edit/Create Modal */}
|
||||
{(editingPlan || isCreating) && (
|
||||
<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 border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-2xl font-black text-zinc-900 dark:text-white">
|
||||
{isCreating ? 'Create Plan' : 'Edit Plan'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => { setEditingPlan(null); setIsCreating(false); }}
|
||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Name (ID)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g. starter"
|
||||
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">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||
placeholder="e.g. Starter"
|
||||
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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Monthly Credits</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.monthly_credits}
|
||||
onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })}
|
||||
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">Price (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Brief description of the plan"
|
||||
rows={3}
|
||||
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 className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-zinc-400 uppercase mb-2">Sort Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
|
||||
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 className="flex items-end">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-zinc-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-bold text-zinc-900 dark:text-white">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-amber-600 hover:bg-amber-700 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check size={18} />
|
||||
{isCreating ? 'Create Plan' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/services/subscription-service.ts
Normal file
322
src/services/subscription-service.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
'use server';
|
||||
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { checkIsAdmin } from './track-api-usage';
|
||||
import { addCredits } from './credit-service';
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
monthly_credits: number;
|
||||
price: number;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserSubscription {
|
||||
user_id: string;
|
||||
plan_id: string | null;
|
||||
started_at: string;
|
||||
last_credit_grant_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscription plans
|
||||
*/
|
||||
export async function getAllPlans(): Promise<SubscriptionPlan[]> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching plans:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (err) {
|
||||
console.error('Error in getAllPlans:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active subscription plans (for users to see)
|
||||
*/
|
||||
export async function getActivePlans(): Promise<SubscriptionPlan[]> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching active plans:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
} catch (err) {
|
||||
console.error('Error in getActivePlans:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription plan (admin only)
|
||||
*/
|
||||
export async function createPlan(plan: Omit<SubscriptionPlan, 'id' | 'created_at' | 'updated_at'>): Promise<{ success: boolean; error?: string; plan?: SubscriptionPlan }> {
|
||||
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 { data, error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.insert(plan)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true, plan: data };
|
||||
} catch (err) {
|
||||
console.error('Error in createPlan:', err);
|
||||
return { success: false, error: 'Failed to create plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a subscription plan (admin only)
|
||||
*/
|
||||
export async function updatePlan(planId: string, updates: Partial<SubscriptionPlan>): 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('subscription_plans')
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', planId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in updatePlan:', err);
|
||||
return { success: false, error: 'Failed to update plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a subscription plan (admin only)
|
||||
*/
|
||||
export async function deletePlan(planId: 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' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscription_plans')
|
||||
.delete()
|
||||
.eq('id', planId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in deletePlan:', err);
|
||||
return { success: false, error: 'Failed to delete plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current subscription
|
||||
*/
|
||||
export async function getUserSubscription(userId: string): Promise<{ subscription: UserSubscription | null; plan: SubscriptionPlan | null }> {
|
||||
try {
|
||||
const supabase = createServerComponentClient({ cookies });
|
||||
|
||||
const { data: subscription, error: subError } = await supabase
|
||||
.from('user_subscriptions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (subError || !subscription) {
|
||||
return { subscription: null, plan: null };
|
||||
}
|
||||
|
||||
if (!subscription.plan_id) {
|
||||
return { subscription, plan: null };
|
||||
}
|
||||
|
||||
const { data: plan, error: planError } = await supabase
|
||||
.from('subscription_plans')
|
||||
.select('*')
|
||||
.eq('id', subscription.plan_id)
|
||||
.single();
|
||||
|
||||
if (planError) {
|
||||
return { subscription, plan: null };
|
||||
}
|
||||
|
||||
return { subscription, plan };
|
||||
} catch (err) {
|
||||
console.error('Error in getUserSubscription:', err);
|
||||
return { subscription: null, plan: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's subscription plan (admin only)
|
||||
*/
|
||||
export async function setUserPlan(userId: string, planId: 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' };
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_subscriptions')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
plan_id: planId,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting user plan:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error in setUserPlan:', err);
|
||||
return { success: false, error: 'Failed to set user plan' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant monthly credits to all users based on their subscription plan
|
||||
* This should be called by a cron job or manually by admin
|
||||
*/
|
||||
export async function grantMonthlyCredits(): 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' };
|
||||
|
||||
// Get all user subscriptions with their plans
|
||||
const { data: subscriptions, error: subError } = await supabase
|
||||
.from('user_subscriptions')
|
||||
.select(`
|
||||
*,
|
||||
plan:plan_id (
|
||||
id,
|
||||
display_name,
|
||||
monthly_credits
|
||||
)
|
||||
`);
|
||||
|
||||
if (subError) {
|
||||
console.error('Error fetching subscriptions:', subError);
|
||||
return { success: false, processed: 0, failed: 0, error: 'Failed to fetch subscriptions' };
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const now = new Date();
|
||||
const oneMonthAgo = new Date(now);
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
for (const sub of subscriptions || []) {
|
||||
// Check if credits were already granted this month
|
||||
const lastGrant = new Date(sub.last_credit_grant_at);
|
||||
if (lastGrant > oneMonthAgo) {
|
||||
continue; // Already granted this month
|
||||
}
|
||||
|
||||
const plan = sub.plan as any;
|
||||
if (!plan || !plan.monthly_credits) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grant credits
|
||||
const result = await addCredits(
|
||||
sub.user_id,
|
||||
plan.monthly_credits,
|
||||
`Monthly credits for ${plan.display_name} plan`,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Update last_credit_grant_at
|
||||
await supabase
|
||||
.from('user_subscriptions')
|
||||
.update({ last_credit_grant_at: now.toISOString() })
|
||||
.eq('user_id', sub.user_id);
|
||||
|
||||
processed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, processed, failed };
|
||||
} catch (err) {
|
||||
console.error('Error in grantMonthlyCredits:', err);
|
||||
return { success: false, processed: 0, failed: 0, error: 'Failed to grant monthly credits' };
|
||||
}
|
||||
}
|
||||
@@ -346,3 +346,78 @@ FOR UPDATE USING (
|
||||
-- FROM auth.users
|
||||
-- ON CONFLICT (user_id) DO UPDATE SET balance = EXCLUDED.balance
|
||||
-- WHERE user_credits.balance = 0;
|
||||
|
||||
-- ============================================
|
||||
-- Subscription Plans System
|
||||
-- ============================================
|
||||
|
||||
-- Subscription plans table
|
||||
CREATE TABLE IF NOT EXISTS subscription_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
monthly_credits INTEGER NOT NULL,
|
||||
price DECIMAL(10, 2) DEFAULT 0,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_plans_active ON subscription_plans(is_active);
|
||||
CREATE INDEX idx_subscription_plans_sort_order ON subscription_plans(sort_order);
|
||||
|
||||
-- User subscriptions table
|
||||
CREATE TABLE IF NOT EXISTS user_subscriptions (
|
||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
plan_id UUID REFERENCES subscription_plans(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||
last_credit_grant_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_subscriptions_plan_id ON user_subscriptions(plan_id);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE subscription_plans ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies for subscription_plans (everyone can view active plans)
|
||||
CREATE POLICY "Anyone can view active plans" ON subscription_plans
|
||||
FOR SELECT USING (is_active = true);
|
||||
|
||||
CREATE POLICY "Admins can manage plans" ON subscription_plans
|
||||
FOR ALL USING (
|
||||
auth.uid() IN (SELECT user_id FROM admin_users)
|
||||
);
|
||||
|
||||
-- Policies for user_subscriptions
|
||||
CREATE POLICY "Users can view their own subscription" ON user_subscriptions
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Admins can view all subscriptions" ON user_subscriptions
|
||||
FOR SELECT USING (
|
||||
auth.uid() IN (SELECT user_id FROM admin_users)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins can manage subscriptions" ON user_subscriptions
|
||||
FOR ALL USING (
|
||||
auth.uid() IN (SELECT user_id FROM admin_users)
|
||||
);
|
||||
|
||||
-- Insert default plans
|
||||
INSERT INTO subscription_plans (name, display_name, monthly_credits, price, description, sort_order) VALUES
|
||||
('starter', 'Starter', 10, 0.00, 'Perfect for occasional use', 1),
|
||||
('bronze', 'Bronze', 50, 4.99, 'Great for regular users', 2),
|
||||
('silver', 'Silver', 100, 8.99, 'Best value for power users', 3),
|
||||
('gold', 'Gold', 250, 19.99, 'Unlimited searches for professionals', 4)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Set all existing users to Starter plan
|
||||
INSERT INTO user_subscriptions (user_id, plan_id)
|
||||
SELECT
|
||||
u.id,
|
||||
(SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1)
|
||||
FROM auth.users u
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
Reference in New Issue
Block a user