- Add membership-plans API with GET (list active plans) and POST (create plan) - Add membership-plans/[id] API with GET (plan details with subscriber count), PUT (update), DELETE (soft delete) - Add memberships API with GET (list with filters) and POST (create membership for client) - Add memberships/[id] API with GET (membership details), PUT (update/renew/change plan), DELETE (cancel) - Add memberships/[id]/renew API for renewing memberships with hour reset - Add clients/[id]/membership API for quick membership lookup (booking discount calculation) - Include benefit summaries and expiring membership detection in responses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
328 lines
8.7 KiB
TypeScript
328 lines
8.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { getServerSession } from 'next-auth';
|
|
import { authOptions } from '@/lib/auth';
|
|
import { db } from '@/lib/db';
|
|
import { z } from 'zod';
|
|
import { Prisma } from '@prisma/client';
|
|
|
|
interface RouteContext {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
// Validation schema for updating a membership plan
|
|
const updateMembershipPlanSchema = z.object({
|
|
name: z.string().min(1).max(100).optional(),
|
|
description: z.string().max(500).optional().nullable(),
|
|
price: z.number().nonnegative().optional(),
|
|
durationMonths: z.number().int().min(1).optional(),
|
|
freeHours: z.number().int().min(0).optional().nullable(),
|
|
bookingDiscount: z.number().min(0).max(100).optional().nullable(),
|
|
storeDiscount: z.number().min(0).max(100).optional().nullable(),
|
|
extraBenefits: z.array(z.string()).optional(),
|
|
isActive: z.boolean().optional(),
|
|
});
|
|
|
|
// GET /api/membership-plans/[id] - Get plan details with subscriber count
|
|
export async function GET(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
const plan = await db.membershipPlan.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
include: {
|
|
memberships: {
|
|
where: {
|
|
status: 'ACTIVE',
|
|
},
|
|
include: {
|
|
client: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
endDate: 'asc',
|
|
},
|
|
},
|
|
_count: {
|
|
select: {
|
|
memberships: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!plan) {
|
|
return NextResponse.json(
|
|
{ error: 'Membership plan not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Calculate statistics
|
|
const now = new Date();
|
|
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const activeMemberships = plan.memberships.filter(m => m.status === 'ACTIVE');
|
|
const expiringMemberships = activeMemberships.filter(
|
|
m => m.endDate <= sevenDaysFromNow && m.endDate > now
|
|
);
|
|
|
|
// Transform response
|
|
const planWithDetails = {
|
|
...plan,
|
|
subscriberCount: activeMemberships.length,
|
|
totalSubscriptions: plan._count.memberships,
|
|
expiringCount: expiringMemberships.length,
|
|
benefitsSummary: {
|
|
freeHours: plan.courtHours || 0,
|
|
bookingDiscount: plan.discountPercent ? Number(plan.discountPercent) : 0,
|
|
extraBenefits: plan.benefits || [],
|
|
},
|
|
activeSubscribers: activeMemberships.map(m => ({
|
|
membershipId: m.id,
|
|
client: m.client,
|
|
startDate: m.startDate,
|
|
endDate: m.endDate,
|
|
remainingHours: m.remainingHours,
|
|
isExpiring: m.endDate <= sevenDaysFromNow,
|
|
})),
|
|
};
|
|
|
|
return NextResponse.json(planWithDetails);
|
|
} catch (error) {
|
|
console.error('Error fetching membership plan:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch membership plan' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// PUT /api/membership-plans/[id] - Update plan
|
|
export async function PUT(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Check if user has admin role
|
|
const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN'];
|
|
if (!allowedRoles.includes(session.user.role)) {
|
|
return NextResponse.json(
|
|
{ error: 'Forbidden: Insufficient permissions' },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
// Verify plan exists and belongs to user's organization
|
|
const existingPlan = await db.membershipPlan.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
});
|
|
|
|
if (!existingPlan) {
|
|
return NextResponse.json(
|
|
{ error: 'Membership plan not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
const body = await request.json();
|
|
|
|
// Validate input
|
|
const validationResult = updateMembershipPlanSchema.safeParse(body);
|
|
if (!validationResult.success) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Invalid membership plan data',
|
|
details: validationResult.error.flatten().fieldErrors,
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const {
|
|
name,
|
|
description,
|
|
price,
|
|
durationMonths,
|
|
freeHours,
|
|
bookingDiscount,
|
|
storeDiscount,
|
|
extraBenefits,
|
|
isActive,
|
|
} = validationResult.data;
|
|
|
|
// Build update data
|
|
const updateData: Prisma.MembershipPlanUpdateInput = {};
|
|
|
|
if (name !== undefined) updateData.name = name;
|
|
if (description !== undefined) updateData.description = description;
|
|
if (price !== undefined) updateData.price = price;
|
|
if (durationMonths !== undefined) updateData.durationMonths = durationMonths;
|
|
if (freeHours !== undefined) updateData.courtHours = freeHours;
|
|
if (bookingDiscount !== undefined) updateData.discountPercent = bookingDiscount;
|
|
if (isActive !== undefined) updateData.isActive = isActive;
|
|
|
|
// Update benefits array if provided
|
|
if (extraBenefits !== undefined || storeDiscount !== undefined) {
|
|
const currentBenefits = existingPlan.benefits || [];
|
|
// Remove old store discount entries
|
|
const filteredBenefits = currentBenefits.filter(
|
|
b => !b.includes('store discount')
|
|
);
|
|
|
|
updateData.benefits = [
|
|
...(extraBenefits !== undefined ? extraBenefits : filteredBenefits),
|
|
...(storeDiscount ? [`${storeDiscount}% store discount`] : []),
|
|
];
|
|
}
|
|
|
|
const plan = await db.membershipPlan.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
memberships: {
|
|
where: {
|
|
status: 'ACTIVE',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Transform response
|
|
const planWithSummary = {
|
|
...plan,
|
|
subscriberCount: plan._count.memberships,
|
|
benefitsSummary: {
|
|
freeHours: plan.courtHours || 0,
|
|
bookingDiscount: plan.discountPercent ? Number(plan.discountPercent) : 0,
|
|
extraBenefits: plan.benefits || [],
|
|
},
|
|
};
|
|
|
|
return NextResponse.json(planWithSummary);
|
|
} catch (error) {
|
|
console.error('Error updating membership plan:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to update membership plan' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// DELETE /api/membership-plans/[id] - Soft delete (set isActive = false)
|
|
export async function DELETE(
|
|
request: NextRequest,
|
|
context: RouteContext
|
|
) {
|
|
try {
|
|
const session = await getServerSession(authOptions);
|
|
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: 'Unauthorized' },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Check if user has admin role
|
|
const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN'];
|
|
if (!allowedRoles.includes(session.user.role)) {
|
|
return NextResponse.json(
|
|
{ error: 'Forbidden: Insufficient permissions' },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const { id } = await context.params;
|
|
|
|
// Verify plan exists and belongs to user's organization
|
|
const existingPlan = await db.membershipPlan.findFirst({
|
|
where: {
|
|
id,
|
|
organizationId: session.user.organizationId,
|
|
},
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
memberships: {
|
|
where: {
|
|
status: 'ACTIVE',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!existingPlan) {
|
|
return NextResponse.json(
|
|
{ error: 'Membership plan not found' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
// Warn if there are active subscriptions
|
|
const activeCount = existingPlan._count.memberships;
|
|
|
|
// Soft delete - set isActive to false
|
|
await db.membershipPlan.update({
|
|
where: { id },
|
|
data: {
|
|
isActive: false,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
message: 'Membership plan deactivated successfully',
|
|
activeSubscriptionsAffected: activeCount,
|
|
note: activeCount > 0
|
|
? 'This plan still has active subscriptions that will remain valid until expiration'
|
|
: undefined,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deactivating membership plan:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to deactivate membership plan' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|