From c08bb3daca9529c20fa5f744208a9c41f0168438 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 1 Feb 2026 07:21:17 +0000 Subject: [PATCH] feat(api): add membership plans and subscriptions endpoints - 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 --- .../app/api/clients/[id]/membership/route.ts | 136 +++++++ .../app/api/membership-plans/[id]/route.ts | 327 +++++++++++++++ apps/web/app/api/membership-plans/route.ts | 182 +++++++++ .../app/api/memberships/[id]/renew/route.ts | 197 +++++++++ apps/web/app/api/memberships/[id]/route.ts | 378 ++++++++++++++++++ apps/web/app/api/memberships/route.ts | 327 +++++++++++++++ 6 files changed, 1547 insertions(+) create mode 100644 apps/web/app/api/clients/[id]/membership/route.ts create mode 100644 apps/web/app/api/membership-plans/[id]/route.ts create mode 100644 apps/web/app/api/membership-plans/route.ts create mode 100644 apps/web/app/api/memberships/[id]/renew/route.ts create mode 100644 apps/web/app/api/memberships/[id]/route.ts create mode 100644 apps/web/app/api/memberships/route.ts diff --git a/apps/web/app/api/clients/[id]/membership/route.ts b/apps/web/app/api/clients/[id]/membership/route.ts new file mode 100644 index 0000000..e7afe13 --- /dev/null +++ b/apps/web/app/api/clients/[id]/membership/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// GET /api/clients/[id]/membership - Get client's current membership (if any) +// This is a quick lookup endpoint for booking discount calculation +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: clientId } = await context.params; + + // Verify client exists and belongs to organization + const client = await db.client.findFirst({ + where: { + id: clientId, + organizationId: session.user.organizationId, + }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }); + + if (!client) { + return NextResponse.json( + { error: 'Client not found' }, + { status: 404 } + ); + } + + // Get client's active membership + const now = new Date(); + + const membership = await db.membership.findFirst({ + where: { + clientId, + status: 'ACTIVE', + endDate: { + gte: now, + }, + }, + include: { + plan: { + select: { + id: true, + name: true, + price: true, + courtHours: true, + discountPercent: true, + benefits: true, + }, + }, + }, + orderBy: { + endDate: 'desc', + }, + }); + + // No active membership + if (!membership) { + return NextResponse.json({ + client, + hasMembership: false, + membership: null, + discounts: { + bookingDiscount: 0, + storeDiscount: 0, + }, + }); + } + + // Calculate discounts and remaining time + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const daysUntilExpiry = Math.ceil((membership.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + + // Extract store discount from benefits array + const storeDiscountBenefit = membership.plan.benefits?.find(b => b.includes('store discount')); + const storeDiscount = storeDiscountBenefit + ? parseInt(storeDiscountBenefit.match(/(\d+)%/)?.[1] || '0', 10) + : 0; + + const response = { + client, + hasMembership: true, + membership: { + id: membership.id, + planId: membership.plan.id, + planName: membership.plan.name, + startDate: membership.startDate, + endDate: membership.endDate, + status: membership.status, + isExpiring: membership.endDate <= sevenDaysFromNow, + daysUntilExpiry, + autoRenew: membership.autoRenew, + }, + benefits: { + freeHours: membership.plan.courtHours || 0, + hoursRemaining: membership.remainingHours || 0, + hoursUsed: membership.plan.courtHours && membership.remainingHours !== null + ? membership.plan.courtHours - membership.remainingHours + : 0, + extraBenefits: membership.plan.benefits || [], + }, + discounts: { + bookingDiscount: membership.plan.discountPercent ? Number(membership.plan.discountPercent) : 0, + storeDiscount, + }, + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching client membership:', error); + return NextResponse.json( + { error: 'Failed to fetch client membership' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/membership-plans/[id]/route.ts b/apps/web/app/api/membership-plans/[id]/route.ts new file mode 100644 index 0000000..0d8c42f --- /dev/null +++ b/apps/web/app/api/membership-plans/[id]/route.ts @@ -0,0 +1,327 @@ +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 } + ); + } +} diff --git a/apps/web/app/api/membership-plans/route.ts b/apps/web/app/api/membership-plans/route.ts new file mode 100644 index 0000000..cc574aa --- /dev/null +++ b/apps/web/app/api/membership-plans/route.ts @@ -0,0 +1,182 @@ +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'; + +// Validation schema for creating a membership plan +const createMembershipPlanSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100, 'Name too long'), + description: z.string().max(500).optional(), + price: z.number().nonnegative('Price must be non-negative'), + durationMonths: z.number().int().min(1, 'Duration must be at least 1 month').default(1), + freeHours: z.number().int().min(0, 'Free hours must be non-negative').optional(), + bookingDiscount: z.number().min(0).max(100, 'Discount must be between 0 and 100').optional(), + storeDiscount: z.number().min(0).max(100, 'Discount must be between 0 and 100').optional(), + extraBenefits: z.array(z.string()).optional(), +}); + +// GET /api/membership-plans - List all active plans for organization +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const includeInactive = searchParams.get('includeInactive') === 'true'; + + // Build where clause + interface MembershipPlanWhereClause { + organizationId: string; + isActive?: boolean; + } + + const whereClause: MembershipPlanWhereClause = { + organizationId: session.user.organizationId, + }; + + // Only show active plans by default + if (!includeInactive) { + whereClause.isActive = true; + } + + const plans = await db.membershipPlan.findMany({ + where: whereClause, + include: { + _count: { + select: { + memberships: { + where: { + status: 'ACTIVE', + }, + }, + }, + }, + }, + orderBy: { + price: 'asc', + }, + }); + + // Transform response to include benefit summary + const plansWithSummary = plans.map((plan) => ({ + ...plan, + subscriberCount: plan._count.memberships, + benefitsSummary: { + freeHours: plan.courtHours || 0, + bookingDiscount: plan.discountPercent ? Number(plan.discountPercent) : 0, + extraBenefits: plan.benefits || [], + }, + })); + + return NextResponse.json(plansWithSummary); + } catch (error) { + console.error('Error fetching membership plans:', error); + return NextResponse.json( + { error: 'Failed to fetch membership plans' }, + { status: 500 } + ); + } +} + +// POST /api/membership-plans - Create a new membership plan +export async function POST(request: NextRequest) { + 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 body = await request.json(); + + // Validate input with Zod schema + const validationResult = createMembershipPlanSchema.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, + } = validationResult.data; + + // Create the membership plan + const plan = await db.membershipPlan.create({ + data: { + organizationId: session.user.organizationId, + name, + description: description || null, + price, + durationMonths, + courtHours: freeHours || null, + discountPercent: bookingDiscount || null, + benefits: [ + ...(extraBenefits || []), + ...(storeDiscount ? [`${storeDiscount}% store discount`] : []), + ], + isActive: true, + }, + 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, { status: 201 }); + } catch (error) { + console.error('Error creating membership plan:', error); + return NextResponse.json( + { error: 'Failed to create membership plan' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/memberships/[id]/renew/route.ts b/apps/web/app/api/memberships/[id]/renew/route.ts new file mode 100644 index 0000000..903d18d --- /dev/null +++ b/apps/web/app/api/memberships/[id]/renew/route.ts @@ -0,0 +1,197 @@ +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'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for renewal +const renewMembershipSchema = z.object({ + planId: z.string().cuid().optional(), // Optional: change plan during renewal + extendMonths: z.number().int().min(1).optional(), // Override default extension +}); + +// POST /api/memberships/[id]/renew - Renew membership +export async function POST( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has appropriate role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN', 'RECEPTIONIST']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify membership exists and belongs to organization + const existingMembership = await db.membership.findFirst({ + where: { + id, + plan: { + organizationId: session.user.organizationId, + }, + }, + include: { + plan: true, + client: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + }, + }); + + if (!existingMembership) { + return NextResponse.json( + { error: 'Membership not found' }, + { status: 404 } + ); + } + + // Cannot renew a cancelled membership + if (existingMembership.status === 'CANCELLED') { + return NextResponse.json( + { error: 'Cannot renew a cancelled membership. Create a new membership instead.' }, + { status: 400 } + ); + } + + // Parse optional body + let body = {}; + try { + body = await request.json(); + } catch { + // Empty body is acceptable + } + + // Validate input + const validationResult = renewMembershipSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid renewal data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { planId, extendMonths } = validationResult.data; + + // Determine which plan to use + let targetPlan = existingMembership.plan; + + if (planId && planId !== existingMembership.planId) { + // Verify new plan exists and is active + const newPlan = await db.membershipPlan.findFirst({ + where: { + id: planId, + organizationId: session.user.organizationId, + isActive: true, + }, + }); + + if (!newPlan) { + return NextResponse.json( + { error: 'New membership plan not found or inactive' }, + { status: 404 } + ); + } + + targetPlan = newPlan; + } + + // Calculate new end date + // Extend from current endDate (not from now) to avoid losing time + const currentEndDate = existingMembership.endDate; + const now = new Date(); + + // If membership is already expired, extend from now instead + const baseDate = currentEndDate > now ? currentEndDate : now; + + const extensionMonths = extendMonths || targetPlan.durationMonths; + const newEndDate = new Date(baseDate); + newEndDate.setMonth(newEndDate.getMonth() + extensionMonths); + + // Update membership + const membership = await db.membership.update({ + where: { id }, + data: { + planId: targetPlan.id, + status: 'ACTIVE', + endDate: newEndDate, + remainingHours: targetPlan.courtHours || null, // Reset hours + // Keep startDate as is - it's the original start + }, + include: { + plan: { + select: { + id: true, + name: true, + price: true, + durationMonths: true, + courtHours: true, + discountPercent: true, + benefits: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + }, + }); + + // Calculate computed fields + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const membershipWithDetails = { + ...membership, + renewed: true, + previousEndDate: existingMembership.endDate, + previousPlan: existingMembership.planId !== targetPlan.id + ? existingMembership.plan.name + : undefined, + isExpiring: membership.endDate <= sevenDaysFromNow, + daysUntilExpiry: Math.ceil((membership.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), + benefitsSummary: { + freeHours: membership.plan.courtHours || 0, + hoursRemaining: membership.remainingHours || 0, + bookingDiscount: membership.plan.discountPercent ? Number(membership.plan.discountPercent) : 0, + extraBenefits: membership.plan.benefits || [], + }, + }; + + return NextResponse.json(membershipWithDetails); + } catch (error) { + console.error('Error renewing membership:', error); + return NextResponse.json( + { error: 'Failed to renew membership' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/memberships/[id]/route.ts b/apps/web/app/api/memberships/[id]/route.ts new file mode 100644 index 0000000..3ddbde4 --- /dev/null +++ b/apps/web/app/api/memberships/[id]/route.ts @@ -0,0 +1,378 @@ +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 +const updateMembershipSchema = z.object({ + status: z.enum(['ACTIVE', 'EXPIRED', 'CANCELLED', 'SUSPENDED']).optional(), + planId: z.string().cuid().optional(), + endDate: z.string().datetime().optional(), + hoursUsed: z.number().int().min(0).optional(), + autoRenew: z.boolean().optional(), + notes: z.string().max(500).optional().nullable(), +}); + +// GET /api/memberships/[id] - Get membership with plan and client details +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 membership = await db.membership.findFirst({ + where: { + id, + plan: { + organizationId: session.user.organizationId, + }, + }, + include: { + plan: { + select: { + id: true, + name: true, + description: true, + price: true, + durationMonths: true, + courtHours: true, + discountPercent: true, + benefits: true, + isActive: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + level: true, + tags: true, + }, + }, + payments: { + select: { + id: true, + amount: true, + paymentType: true, + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, + }, + }, + }); + + if (!membership) { + return NextResponse.json( + { error: 'Membership not found' }, + { status: 404 } + ); + } + + // Calculate computed fields + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const hoursUsed = membership.plan.courtHours && membership.remainingHours !== null + ? membership.plan.courtHours - membership.remainingHours + : 0; + + const membershipWithDetails = { + ...membership, + isExpiring: membership.status === 'ACTIVE' && membership.endDate <= sevenDaysFromNow, + isExpired: membership.status === 'ACTIVE' && membership.endDate < now, + daysUntilExpiry: membership.status === 'ACTIVE' + ? Math.ceil((membership.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : null, + hoursUsed, + benefitsSummary: { + freeHours: membership.plan.courtHours || 0, + hoursRemaining: membership.remainingHours || 0, + hoursUsed, + bookingDiscount: membership.plan.discountPercent ? Number(membership.plan.discountPercent) : 0, + extraBenefits: membership.plan.benefits || [], + }, + totalPaid: membership.payments.reduce( + (sum, p) => sum + Number(p.amount), + 0 + ), + }; + + return NextResponse.json(membershipWithDetails); + } catch (error) { + console.error('Error fetching membership:', error); + return NextResponse.json( + { error: 'Failed to fetch membership' }, + { status: 500 } + ); + } +} + +// PUT /api/memberships/[id] - Update membership (renew, cancel, change 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 appropriate role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN', 'RECEPTIONIST']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify membership exists and belongs to organization + const existingMembership = await db.membership.findFirst({ + where: { + id, + plan: { + organizationId: session.user.organizationId, + }, + }, + include: { + plan: true, + }, + }); + + if (!existingMembership) { + return NextResponse.json( + { error: 'Membership not found' }, + { status: 404 } + ); + } + + const body = await request.json(); + + // Validate input + const validationResult = updateMembershipSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid membership data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { status, planId, endDate, hoursUsed, autoRenew, notes } = validationResult.data; + + // Build update data + const updateData: Prisma.MembershipUpdateInput = {}; + + if (status !== undefined) updateData.status = status; + if (endDate !== undefined) updateData.endDate = new Date(endDate); + if (autoRenew !== undefined) updateData.autoRenew = autoRenew; + if (notes !== undefined) updateData.notes = notes; + + // Handle hours used - update remaining hours + if (hoursUsed !== undefined && existingMembership.plan.courtHours) { + const newRemainingHours = Math.max(0, existingMembership.plan.courtHours - hoursUsed); + updateData.remainingHours = newRemainingHours; + } + + // Handle plan change + if (planId !== undefined && planId !== existingMembership.planId) { + // Verify new plan exists and belongs to organization + const newPlan = await db.membershipPlan.findFirst({ + where: { + id: planId, + organizationId: session.user.organizationId, + isActive: true, + }, + }); + + if (!newPlan) { + return NextResponse.json( + { error: 'New membership plan not found or inactive' }, + { status: 404 } + ); + } + + updateData.plan = { + connect: { id: planId }, + }; + + // Reset remaining hours to new plan's court hours + if (newPlan.courtHours !== null) { + updateData.remainingHours = newPlan.courtHours; + } + } + + const membership = await db.membership.update({ + where: { id }, + data: updateData, + include: { + plan: { + select: { + id: true, + name: true, + price: true, + durationMonths: true, + courtHours: true, + discountPercent: true, + benefits: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + }, + }); + + // Add computed fields + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const membershipWithDetails = { + ...membership, + isExpiring: membership.status === 'ACTIVE' && membership.endDate <= sevenDaysFromNow, + daysUntilExpiry: membership.status === 'ACTIVE' + ? Math.ceil((membership.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : null, + benefitsSummary: { + freeHours: membership.plan.courtHours || 0, + hoursRemaining: membership.remainingHours || 0, + bookingDiscount: membership.plan.discountPercent ? Number(membership.plan.discountPercent) : 0, + extraBenefits: membership.plan.benefits || [], + }, + }; + + return NextResponse.json(membershipWithDetails); + } catch (error) { + console.error('Error updating membership:', error); + return NextResponse.json( + { error: 'Failed to update membership' }, + { status: 500 } + ); + } +} + +// DELETE /api/memberships/[id] - Cancel membership (set status = CANCELLED) +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 appropriate role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify membership exists and belongs to organization + const existingMembership = await db.membership.findFirst({ + where: { + id, + plan: { + organizationId: session.user.organizationId, + }, + }, + include: { + client: { + select: { + id: true, + firstName: true, + lastName: true, + }, + }, + plan: { + select: { + name: true, + }, + }, + }, + }); + + if (!existingMembership) { + return NextResponse.json( + { error: 'Membership not found' }, + { status: 404 } + ); + } + + // Already cancelled + if (existingMembership.status === 'CANCELLED') { + return NextResponse.json( + { error: 'Membership is already cancelled' }, + { status: 400 } + ); + } + + // Cancel the membership + await db.membership.update({ + where: { id }, + data: { + status: 'CANCELLED', + autoRenew: false, + }, + }); + + return NextResponse.json({ + message: 'Membership cancelled successfully', + membershipId: id, + client: `${existingMembership.client.firstName} ${existingMembership.client.lastName}`, + plan: existingMembership.plan.name, + previousStatus: existingMembership.status, + }); + } catch (error) { + console.error('Error cancelling membership:', error); + return NextResponse.json( + { error: 'Failed to cancel membership' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/memberships/route.ts b/apps/web/app/api/memberships/route.ts new file mode 100644 index 0000000..56d575b --- /dev/null +++ b/apps/web/app/api/memberships/route.ts @@ -0,0 +1,327 @@ +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'; + +// Validation schema for creating a membership +const createMembershipSchema = z.object({ + clientId: z.string().cuid('Invalid client ID'), + planId: z.string().cuid('Invalid plan ID'), + startDate: z.string().datetime({ message: 'Invalid start date format' }), + endDate: z.string().datetime({ message: 'Invalid end date format' }), + autoRenew: z.boolean().optional().default(false), + notes: z.string().max(500).optional(), +}); + +// GET /api/memberships - List all memberships with filters +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get('status'); + const planId = searchParams.get('planId'); + const search = searchParams.get('search'); + const expiringOnly = searchParams.get('expiring') === 'true'; + const limit = searchParams.get('limit'); + const offset = searchParams.get('offset'); + + // Build where clause - we need to get memberships from clients in the organization + interface MembershipWhereClause { + plan: { + organizationId: string; + }; + status?: 'ACTIVE' | 'EXPIRED' | 'CANCELLED' | 'SUSPENDED'; + planId?: string; + endDate?: { + gte?: Date; + lte?: Date; + }; + client?: { + OR?: Array<{ + firstName?: { contains: string; mode: 'insensitive' }; + lastName?: { contains: string; mode: 'insensitive' }; + email?: { contains: string; mode: 'insensitive' }; + }>; + }; + } + + const whereClause: MembershipWhereClause = { + plan: { + organizationId: session.user.organizationId, + }, + }; + + // Filter by status + if (status) { + const validStatuses = ['ACTIVE', 'EXPIRED', 'CANCELLED', 'SUSPENDED'] as const; + const upperStatus = status.toUpperCase(); + if (validStatuses.includes(upperStatus as typeof validStatuses[number])) { + whereClause.status = upperStatus as typeof validStatuses[number]; + } + } + + // Filter by plan + if (planId) { + whereClause.planId = planId; + } + + // Filter by expiring (within 7 days) + if (expiringOnly) { + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + whereClause.status = 'ACTIVE'; + whereClause.endDate = { + gte: now, + lte: sevenDaysFromNow, + }; + } + + // Search by client name or email + if (search && search.trim().length > 0) { + const searchTerm = search.trim(); + whereClause.client = { + OR: [ + { firstName: { contains: searchTerm, mode: 'insensitive' } }, + { lastName: { contains: searchTerm, mode: 'insensitive' } }, + { email: { contains: searchTerm, mode: 'insensitive' } }, + ], + }; + } + + // Pagination + const take = limit ? Math.min(parseInt(limit, 10), 100) : 50; + const skip = offset ? parseInt(offset, 10) : 0; + + // Get total count + const totalCount = await db.membership.count({ + where: whereClause, + }); + + const memberships = await db.membership.findMany({ + where: whereClause, + include: { + plan: { + select: { + id: true, + name: true, + price: true, + durationMonths: true, + courtHours: true, + discountPercent: true, + benefits: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + }, + orderBy: [ + { status: 'asc' }, + { endDate: 'asc' }, + ], + take, + skip, + }); + + // Add computed fields + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const membershipsWithDetails = memberships.map((membership) => ({ + ...membership, + isExpiring: membership.status === 'ACTIVE' && membership.endDate <= sevenDaysFromNow, + daysUntilExpiry: membership.status === 'ACTIVE' + ? Math.ceil((membership.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : null, + benefitsSummary: { + freeHours: membership.plan.courtHours || 0, + hoursRemaining: membership.remainingHours || 0, + bookingDiscount: membership.plan.discountPercent ? Number(membership.plan.discountPercent) : 0, + extraBenefits: membership.plan.benefits || [], + }, + })); + + return NextResponse.json({ + data: membershipsWithDetails, + pagination: { + total: totalCount, + limit: take, + offset: skip, + hasMore: skip + take < totalCount, + }, + }); + } catch (error) { + console.error('Error fetching memberships:', error); + return NextResponse.json( + { error: 'Failed to fetch memberships' }, + { status: 500 } + ); + } +} + +// POST /api/memberships - Create membership for client +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has appropriate role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN', 'RECEPTIONIST']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validate input + const validationResult = createMembershipSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid membership data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { clientId, planId, startDate, endDate, autoRenew, notes } = validationResult.data; + + // Verify client belongs to organization + const client = await db.client.findFirst({ + where: { + id: clientId, + organizationId: session.user.organizationId, + }, + }); + + if (!client) { + return NextResponse.json( + { error: 'Client not found' }, + { status: 404 } + ); + } + + // Verify plan belongs to organization and is active + const plan = await db.membershipPlan.findFirst({ + where: { + id: planId, + organizationId: session.user.organizationId, + isActive: true, + }, + }); + + if (!plan) { + return NextResponse.json( + { error: 'Membership plan not found or inactive' }, + { status: 404 } + ); + } + + // Check if client already has an active membership + const existingMembership = await db.membership.findFirst({ + where: { + clientId, + status: 'ACTIVE', + endDate: { + gte: new Date(), + }, + }, + }); + + if (existingMembership) { + return NextResponse.json( + { + error: 'Client already has an active membership', + existingMembershipId: existingMembership.id, + expiresAt: existingMembership.endDate, + }, + { status: 409 } + ); + } + + // Create the membership + const membership = await db.membership.create({ + data: { + planId, + clientId, + startDate: new Date(startDate), + endDate: new Date(endDate), + status: 'ACTIVE', + remainingHours: plan.courtHours || null, + autoRenew: autoRenew || false, + notes: notes || null, + }, + include: { + plan: { + select: { + id: true, + name: true, + price: true, + durationMonths: true, + courtHours: true, + discountPercent: true, + benefits: true, + }, + }, + client: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + phone: true, + }, + }, + }, + }); + + // Add computed fields + const now = new Date(); + const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const membershipWithDetails = { + ...membership, + isExpiring: membership.endDate <= sevenDaysFromNow, + daysUntilExpiry: Math.ceil((membership.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), + benefitsSummary: { + freeHours: membership.plan.courtHours || 0, + hoursRemaining: membership.remainingHours || 0, + bookingDiscount: membership.plan.discountPercent ? Number(membership.plan.discountPercent) : 0, + extraBenefits: membership.plan.benefits || [], + }, + }; + + return NextResponse.json(membershipWithDetails, { status: 201 }); + } catch (error) { + console.error('Error creating membership:', error); + return NextResponse.json( + { error: 'Failed to create membership' }, + { status: 500 } + ); + } +}