Add IsFree subscription toggle to bypass all tier limitations

- Add IsFree boolean field to UserSubscription model
- When IsFree is true, user sees limitations_enabled=false regardless of global setting
- CheckLimit() bypasses all limit checks for IsFree users
- Add admin endpoint GET /api/admin/subscriptions/user/:user_id
- Add IsFree toggle to admin user detail page under Subscription card
- Add database migration 004_subscription_is_free
- Add integration tests for IsFree functionality
- Add task kanban categorization documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-01 18:05:41 -06:00
parent 0a708c092d
commit 0c86611a10
14 changed files with 750 additions and 3 deletions
@@ -7,12 +7,13 @@ import Link from 'next/link';
import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react';
import { toast } from 'sonner';
import { usersApi, notificationsApi, emailsApi } from '@/lib/api';
import { usersApi, notificationsApi, emailsApi, subscriptionsApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
Card,
CardContent,
@@ -51,6 +52,26 @@ export function UserDetailClient() {
enabled: !!userId,
});
const { data: subscription } = useQuery({
queryKey: ['subscription', 'user', userId],
queryFn: () => subscriptionsApi.getByUser(userId),
enabled: !!userId,
});
const updateSubscriptionMutation = useMutation({
mutationFn: (data: { is_free: boolean }) => {
if (!subscription) throw new Error('No subscription');
return subscriptionsApi.update(subscription.id, data);
},
onSuccess: () => {
toast.success('Subscription updated');
queryClient.invalidateQueries({ queryKey: ['subscription', 'user', userId] });
},
onError: () => {
toast.error('Failed to update subscription');
},
});
const deleteMutation = useMutation({
mutationFn: () => usersApi.delete(userId),
onSuccess: () => {
@@ -258,6 +279,71 @@ export function UserDetailClient() {
</CardContent>
</Card>
{/* Subscription */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Subscription</CardTitle>
<CardDescription>User subscription and access settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">
Tier
</div>
<Badge variant={subscription?.tier === 'pro' ? 'default' : 'secondary'}>
{subscription?.tier || 'free'}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Platform
</div>
<div>{subscription?.platform || '-'}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Auto Renew
</div>
<Badge variant={subscription?.auto_renew ? 'default' : 'outline'}>
{subscription?.auto_renew ? 'Yes' : 'No'}
</Badge>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Expires
</div>
<div>
{subscription?.expires_at
? new Date(subscription.expires_at).toLocaleDateString()
: '-'}
</div>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="is-free" className="text-base">
Free Access (No Limitations)
</Label>
<p className="text-sm text-muted-foreground">
When enabled, this user bypasses all tier limitations regardless of subscription status or global settings.
</p>
</div>
<Switch
id="is-free"
checked={subscription?.is_free ?? false}
onCheckedChange={(checked) => {
updateSubscriptionMutation.mutate({ is_free: checked });
}}
disabled={updateSubscriptionMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Residences */}
{user.residences && user.residences.length > 0 && (
<Card className="md:col-span-2">
+5
View File
@@ -335,6 +335,11 @@ export const subscriptionsApi = {
return response.data;
},
getByUser: async (userId: number): Promise<Subscription> => {
const response = await api.get<Subscription>(`/subscriptions/user/${userId}`);
return response.data;
},
update: async (id: number, data: UpdateSubscriptionRequest): Promise<Subscription> => {
const response = await api.put<Subscription>(`/subscriptions/${id}`, data);
return response.data;
+2
View File
@@ -472,6 +472,7 @@ export interface Subscription {
tier: 'free' | 'premium' | 'pro';
platform: string;
auto_renew: boolean;
is_free: boolean;
subscribed_at?: string;
expires_at?: string;
cancelled_at?: string;
@@ -491,6 +492,7 @@ export interface SubscriptionListParams extends ListParams {
export interface UpdateSubscriptionRequest {
tier?: string;
auto_renew?: boolean;
is_free?: boolean;
}
export interface SubscriptionStats {