mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 15:24:29 +00:00
feat: Implement FASE 5 (Clients & Loyalty) and FASE 6 (Payments & Financial)
FASE 5 - Clientes y Fidelización: - Client Management (CRM) con búsqueda fonética - Galería de fotos restringida por tier (VIP/Black/Gold) - Sistema de Lealtad con puntos y expiración (6 meses) - Membresías (Gold, Black, VIP) con beneficios configurables - Notas técnicas con timestamp APIs Implementadas: - GET/POST /api/aperture/clients - CRUD completo de clientes - GET /api/aperture/clients/[id] - Detalles con historial de reservas - POST /api/aperture/clients/[id]/notes - Notas técnicas - GET/POST /api/aperture/clients/[id]/photos - Galería de fotos - GET /api/aperture/loyalty - Resumen de lealtad - GET/POST /api/aperture/loyalty/[customerId] - Historial y puntos FASE 6 - Pagos y Protección: - Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded) - No-Show Logic con detección automática (ventana 12h) - Check-in de clientes para prevenir no-shows - Override Admin para waivar penalizaciones - Finanzas y Reportes (expenses, daily closing, staff performance) APIs Implementadas: - POST /api/webhooks/stripe - Handler de webhooks Stripe - GET /api/cron/detect-no-shows - Detectar no-shows (cron job) - POST /api/aperture/bookings/no-show - Aplicar penalización - POST /api/aperture/bookings/check-in - Registrar check-in - GET /api/aperture/finance - Resumen financiero - POST/GET /api/aperture/finance/daily-closing - Reportes diarios - GET/POST /api/aperture/finance/expenses - Gestión de gastos - GET /api/aperture/finance/staff-performance - Performance de staff Documentación: - docs/APERATURE_SPECS.md - Especificaciones técnicas completas - docs/APERTURE_SQUARE_UI.md - Ejemplos de Radix UI con Square UI - docs/API.md - Actualizado con nuevas rutas Migraciones SQL: - 20260118050000_clients_loyalty_system.sql - Clientes, fotos, lealtad, membresías - 20260118060000_stripe_webhooks_noshow_logic.sql - Webhooks, no-shows, check-ins - 20260118070000_financial_reporting_expenses.sql - Gastos, reportes financieros
This commit is contained in:
60
app/api/aperture/bookings/check-in/route.ts
Normal file
60
app/api/aperture/bookings/check-in/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Record check-in for a booking
|
||||
* @param {NextRequest} request - Body with booking_id and staff_id
|
||||
* @returns {NextResponse} Check-in result
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { booking_id, staff_id } = body
|
||||
|
||||
if (!booking_id || !staff_id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Booking ID and Staff ID are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Record check-in
|
||||
const { data: success, error } = await supabaseAdmin.rpc('record_booking_checkin', {
|
||||
p_booking_id: booking_id,
|
||||
p_staff_id: staff_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error recording check-in:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Check-in already recorded or booking not found' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated booking details
|
||||
const { data: booking } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('id', booking_id)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: booking
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/bookings/check-in:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
app/api/aperture/bookings/no-show/route.ts
Normal file
53
app/api/aperture/bookings/no-show/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Apply no-show penalty to a specific booking
|
||||
* @param {NextRequest} request - Body with booking_id and optional override_by (admin)
|
||||
* @returns {NextResponse} Penalty application result
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { booking_id, override_by } = body
|
||||
|
||||
if (!booking_id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Booking ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Apply penalty
|
||||
const { error } = await supabaseAdmin.rpc('apply_no_show_penalty', {
|
||||
p_booking_id: booking_id,
|
||||
p_override_by: override_by || null
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error applying no-show penalty:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated booking details
|
||||
const { data: booking } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('id', booking_id)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: booking
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/bookings/no-show:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
84
app/api/aperture/clients/[id]/notes/route.ts
Normal file
84
app/api/aperture/clients/[id]/notes/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Add technical note to client
|
||||
* @param {NextRequest} request - Body with note content
|
||||
* @returns {NextResponse} Updated customer with notes
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
const { note } = await request.json()
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note content is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get current customer
|
||||
const { data: customer, error: fetchError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('notes, technical_notes')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Append new technical note
|
||||
const existingNotes = customer.technical_notes || ''
|
||||
const timestamp = new Date().toISOString()
|
||||
const newNoteEntry = `[${timestamp}] ${note}`
|
||||
const updatedNotes = existingNotes
|
||||
? `${existingNotes}\n${newNoteEntry}`
|
||||
: newNoteEntry
|
||||
|
||||
// Update customer
|
||||
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.update({
|
||||
technical_notes: updatedNotes,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', clientId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error adding technical note:', updateError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: updateError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer',
|
||||
entity_id: clientId,
|
||||
action: 'technical_note_added',
|
||||
new_values: { note }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedCustomer
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/clients/[id]/notes:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
152
app/api/aperture/clients/[id]/photos/route.ts
Normal file
152
app/api/aperture/clients/[id]/photos/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get client photo gallery (VIP/Black/Gold only)
|
||||
* @param {NextRequest} request - URL params: clientId in path
|
||||
* @returns {NextResponse} Client photos with metadata
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
|
||||
// Check if customer tier allows photo access
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('tier')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check tier access
|
||||
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
|
||||
if (!canAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Photo gallery not available for this tier' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get photos
|
||||
const { data: photos, error: photosError } = await supabaseAdmin
|
||||
.from('customer_photos')
|
||||
.select(`
|
||||
*,
|
||||
creator:auth.users(id, email)
|
||||
`)
|
||||
.eq('customer_id', clientId)
|
||||
.eq('is_active', true)
|
||||
.order('taken_at', { ascending: false })
|
||||
|
||||
if (photosError) {
|
||||
console.error('Error fetching photos:', photosError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch photos' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: photos || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/clients/[id]/photos:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Upload photo to client gallery (VIP/Black/Gold only)
|
||||
* @param {NextRequest} request - Body with photo data
|
||||
* @returns {NextResponse} Uploaded photo metadata
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
const { storage_path, description } = await request.json()
|
||||
|
||||
if (!storage_path) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Storage path is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if customer tier allows photo gallery
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('tier')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
|
||||
if (!canAccess) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Photo gallery not available for this tier' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create photo record
|
||||
const { data: photo, error: photoError } = await supabaseAdmin
|
||||
.from('customer_photos')
|
||||
.insert({
|
||||
customer_id: clientId,
|
||||
storage_path,
|
||||
description,
|
||||
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (photoError) {
|
||||
console.error('Error uploading photo:', photoError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: photoError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer_photo',
|
||||
entity_id: photo.id,
|
||||
action: 'upload',
|
||||
new_values: { customer_id: clientId, storage_path }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: photo
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/clients/[id]/photos:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
173
app/api/aperture/clients/[id]/route.ts
Normal file
173
app/api/aperture/clients/[id]/route.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get specific client details with full history
|
||||
* @param {NextRequest} request - URL params: clientId in path
|
||||
* @returns {NextResponse} Client details with bookings, loyalty, photos
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
|
||||
// Get customer basic info
|
||||
const { data: customer, error: customerError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get recent bookings
|
||||
const { data: bookings, error: bookingsError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select(`
|
||||
*,
|
||||
service:services(name, base_price, duration_minutes),
|
||||
location:locations(name),
|
||||
staff:staff(id, first_name, last_name)
|
||||
`)
|
||||
.eq('customer_id', clientId)
|
||||
.order('start_time_utc', { ascending: false })
|
||||
.limit(20)
|
||||
|
||||
if (bookingsError) {
|
||||
console.error('Error fetching bookings:', bookingsError)
|
||||
}
|
||||
|
||||
// Get loyalty summary
|
||||
const { data: loyaltyTransactions, error: loyaltyError } = await supabaseAdmin
|
||||
.from('loyalty_transactions')
|
||||
.select('*')
|
||||
.eq('customer_id', clientId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
|
||||
if (loyaltyError) {
|
||||
console.error('Error fetching loyalty transactions:', loyaltyError)
|
||||
}
|
||||
|
||||
// Get photos (if tier allows)
|
||||
let photos = []
|
||||
const canAccessPhotos = ['gold', 'black', 'VIP'].includes(customer.tier)
|
||||
|
||||
if (canAccessPhotos) {
|
||||
const { data: photosData, error: photosError } = await supabaseAdmin
|
||||
.from('customer_photos')
|
||||
.select('*')
|
||||
.eq('customer_id', clientId)
|
||||
.eq('is_active', true)
|
||||
.order('taken_at', { ascending: false })
|
||||
.limit(20)
|
||||
|
||||
if (!photosError) {
|
||||
photos = photosData
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription (if any)
|
||||
const { data: subscription, error: subError } = await supabaseAdmin
|
||||
.from('customer_subscriptions')
|
||||
.select(`
|
||||
*,
|
||||
membership_plan:membership_plans(name, tier, benefits)
|
||||
`)
|
||||
.eq('customer_id', clientId)
|
||||
.eq('status', 'active')
|
||||
.single()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
customer,
|
||||
bookings: bookings || [],
|
||||
loyalty_transactions: loyaltyTransactions || [],
|
||||
photos,
|
||||
subscription: subError ? null : subscription
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/clients/[id]:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Update client information
|
||||
* @param {NextRequest} request - Body with updated client data
|
||||
* @returns {NextResponse} Updated client data
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { clientId: string } }
|
||||
) {
|
||||
try {
|
||||
const { clientId } = params
|
||||
const body = await request.json()
|
||||
|
||||
// Get current customer
|
||||
const { data: currentCustomer, error: fetchError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', clientId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !currentCustomer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update customer
|
||||
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.update({
|
||||
...body,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', clientId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating client:', updateError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: updateError.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer',
|
||||
entity_id: clientId,
|
||||
action: 'update',
|
||||
old_values: currentCustomer,
|
||||
new_values: updatedCustomer
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: updatedCustomer
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in PUT /api/aperture/clients/[id]:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
154
app/api/aperture/clients/route.ts
Normal file
154
app/api/aperture/clients/route.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description List and search clients with phonetic search, history, and technical notes
|
||||
* @param {NextRequest} request - Query params: q (search query), tier (filter by tier), limit (results limit), offset (pagination offset)
|
||||
* @returns {NextResponse} List of clients with their details
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const q = searchParams.get('q') || ''
|
||||
const tier = searchParams.get('tier')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('customers')
|
||||
.select(`
|
||||
*,
|
||||
bookings:bookings(
|
||||
id,
|
||||
short_id,
|
||||
service_id,
|
||||
start_time_utc,
|
||||
status,
|
||||
total_price
|
||||
)
|
||||
`, { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
// Apply tier filter
|
||||
if (tier) {
|
||||
query = query.eq('tier', tier)
|
||||
}
|
||||
|
||||
// Apply phonetic search if query provided
|
||||
if (q) {
|
||||
const searchTerm = `%${q}%`
|
||||
query = query.or(`first_name.ilike.${searchTerm},last_name.ilike.${searchTerm},email.ilike.${searchTerm},phone.ilike.${searchTerm}`)
|
||||
}
|
||||
|
||||
const { data: customers, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching clients:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch clients' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: customers,
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in /api/aperture/clients:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Create new client
|
||||
* @param {NextRequest} request - Body with client details
|
||||
* @returns {NextResponse} Created client data
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone,
|
||||
tier = 'free',
|
||||
notes,
|
||||
preferences,
|
||||
referral_code
|
||||
} = body
|
||||
|
||||
// Validate required fields
|
||||
if (!first_name || !last_name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'First name and last name are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate unique referral code if not provided
|
||||
let finalReferralCode = referral_code
|
||||
if (!finalReferralCode) {
|
||||
finalReferralCode = `${first_name.toLowerCase().replace(/[^a-z]/g, '')}${last_name.toLowerCase().replace(/[^a-z]/g, '')}${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
// Create customer
|
||||
const { data: customer, error } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.insert({
|
||||
first_name,
|
||||
last_name,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
tier,
|
||||
notes,
|
||||
preferences: preferences || {},
|
||||
referral_code: finalReferralCode
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating client:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'customer',
|
||||
entity_id: customer.id,
|
||||
action: 'create',
|
||||
new_values: {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
tier
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: customer
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/clients:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
68
app/api/aperture/finance/daily-closing/route.ts
Normal file
68
app/api/aperture/finance/daily-closing/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get daily closing reports
|
||||
* @param {NextRequest} request - Query params: location_id, start_date, end_date, status
|
||||
* @returns {NextResponse} List of daily closing reports
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
const status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('daily_closing_reports')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('report_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (location_id) {
|
||||
query = query.eq('location_id', location_id)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
query = query.gte('report_date', start_date)
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
query = query.lte('report_date', end_date)
|
||||
}
|
||||
|
||||
const { data: reports, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching daily closing reports:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch daily closing reports' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: reports || [],
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance/daily-closing:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
143
app/api/aperture/finance/expenses/route.ts
Normal file
143
app/api/aperture/finance/expenses/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Create expense record
|
||||
* @param {NextRequest} request - Body with expense details
|
||||
* @returns {NextResponse} Created expense
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
location_id,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
expense_date,
|
||||
payment_method,
|
||||
receipt_url,
|
||||
notes
|
||||
} = body
|
||||
|
||||
if (!category || !description || !amount || !expense_date) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'category, description, amount, and expense_date are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: expense, error } = await supabaseAdmin
|
||||
.from('expenses')
|
||||
.insert({
|
||||
location_id,
|
||||
category,
|
||||
description,
|
||||
amount,
|
||||
expense_date,
|
||||
payment_method,
|
||||
receipt_url,
|
||||
notes,
|
||||
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating expense:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
await supabaseAdmin.from('audit_logs').insert({
|
||||
entity_type: 'expense',
|
||||
entity_id: expense.id,
|
||||
action: 'create',
|
||||
new_values: {
|
||||
category,
|
||||
description,
|
||||
amount
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: expense
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/finance/expenses:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Get expenses with filters
|
||||
* @param {NextRequest} request - Query params: location_id, category, start_date, end_date
|
||||
* @returns {NextResponse} List of expenses
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const category = searchParams.get('category')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabaseAdmin
|
||||
.from('expenses')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('expense_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (location_id) {
|
||||
query = query.eq('location_id', location_id)
|
||||
}
|
||||
|
||||
if (category) {
|
||||
query = query.eq('category', category)
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
query = query.gte('expense_date', start_date)
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
query = query.lte('expense_date', end_date)
|
||||
}
|
||||
|
||||
const { data: expenses, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching expenses:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch expenses' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: expenses || [],
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance/expenses:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
app/api/aperture/finance/route.ts
Normal file
49
app/api/aperture/finance/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get financial summary for date range and location
|
||||
* @param {NextRequest} request - Query params: location_id, start_date, end_date
|
||||
* @returns {NextResponse} Financial summary with revenue, expenses, and profit
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
|
||||
if (!start_date || !end_date) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'start_date and end_date are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get financial summary
|
||||
const { data: summary, error } = await supabaseAdmin.rpc('get_financial_summary', {
|
||||
p_location_id: location_id || null,
|
||||
p_start_date: start_date,
|
||||
p_end_date: end_date
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching financial summary:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch financial summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: summary
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
app/api/aperture/finance/staff-performance/route.ts
Normal file
49
app/api/aperture/finance/staff-performance/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get staff performance report for date range
|
||||
* @param {NextRequest} request - Query params: location_id, start_date, end_date
|
||||
* @returns {NextResponse} Staff performance metrics per staff member
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const location_id = searchParams.get('location_id')
|
||||
const start_date = searchParams.get('start_date')
|
||||
const end_date = searchParams.get('end_date')
|
||||
|
||||
if (!location_id || !start_date || !end_date) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'location_id, start_date, and end_date are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get staff performance report
|
||||
const { data: report, error } = await supabaseAdmin.rpc('get_staff_performance_report', {
|
||||
p_location_id: location_id,
|
||||
p_start_date: start_date,
|
||||
p_end_date: end_date
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching staff performance report:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch staff performance report' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: report
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/finance/staff-performance:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
app/api/aperture/loyalty/[customerId]/route.ts
Normal file
134
app/api/aperture/loyalty/[customerId]/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get loyalty history for specific customer
|
||||
* @param {NextRequest} request - URL params: customerId in path
|
||||
* @returns {NextResponse} Customer loyalty transactions and history
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { customerId: string } }
|
||||
) {
|
||||
try {
|
||||
const { customerId } = params
|
||||
|
||||
// Get loyalty summary
|
||||
const { data: summary, error: summaryError } = await supabaseAdmin
|
||||
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
|
||||
|
||||
if (summaryError) {
|
||||
console.error('Error fetching loyalty summary:', summaryError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch loyalty summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get loyalty transactions with pagination
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { data: transactions, error: transactionsError, count } = await supabaseAdmin
|
||||
.from('loyalty_transactions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('customer_id', customerId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (transactionsError) {
|
||||
console.error('Error fetching loyalty transactions:', transactionsError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch loyalty transactions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
transactions: transactions || [],
|
||||
pagination: {
|
||||
total: count || 0,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: (count || 0) > offset + limit
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/loyalty/[customerId]:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Add or remove loyalty points for customer
|
||||
* @param {NextRequest} request - Body with points, transaction_type, description, reference_type, reference_id
|
||||
* @returns {NextResponse} Transaction result and updated summary
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { customerId: string } }
|
||||
) {
|
||||
try {
|
||||
const { customerId } = params
|
||||
const body = await request.json()
|
||||
const {
|
||||
points,
|
||||
transaction_type = 'admin_adjustment',
|
||||
description,
|
||||
reference_type,
|
||||
reference_id
|
||||
} = body
|
||||
|
||||
if (!points || typeof points !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Points amount is required and must be a number' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add loyalty points
|
||||
const { data: transactionId, error: error } = await supabaseAdmin
|
||||
.rpc('add_loyalty_points', {
|
||||
p_customer_id: customerId,
|
||||
p_points: points,
|
||||
p_transaction_type: transaction_type,
|
||||
p_description: description,
|
||||
p_reference_type: reference_type,
|
||||
p_reference_id: reference_id
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding loyalty points:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated summary
|
||||
const { data: summary } = await supabaseAdmin
|
||||
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
transaction_id: transactionId,
|
||||
summary
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in POST /api/aperture/loyalty/[customerId]/points:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
92
app/api/aperture/loyalty/route.ts
Normal file
92
app/api/aperture/loyalty/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description Get loyalty points and rewards for current customer
|
||||
* @param {NextRequest} request - Query params: customerId (optional, defaults to authenticated user)
|
||||
* @returns {NextResponse} Loyalty summary with points, transactions, and rewards
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const customerId = searchParams.get('customerId')
|
||||
|
||||
// Get customer ID from auth or query param
|
||||
let targetCustomerId = customerId
|
||||
|
||||
// If no customerId provided, get from authenticated user
|
||||
if (!targetCustomerId) {
|
||||
const { data: { user } } = await supabaseAdmin.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: customer } = await supabaseAdmin
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!customer) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Customer not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
targetCustomerId = customer.id
|
||||
}
|
||||
|
||||
// Get loyalty summary
|
||||
const { data: summary, error: summaryError } = await supabaseAdmin
|
||||
.rpc('get_customer_loyalty_summary', { p_customer_id: targetCustomerId })
|
||||
|
||||
if (summaryError) {
|
||||
console.error('Error fetching loyalty summary:', summaryError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch loyalty summary' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get recent transactions
|
||||
const { data: transactions, error: transactionsError } = await supabaseAdmin
|
||||
.from('loyalty_transactions')
|
||||
.select('*')
|
||||
.eq('customer_id', targetCustomerId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
if (transactionsError) {
|
||||
console.error('Error fetching loyalty transactions:', transactionsError)
|
||||
}
|
||||
|
||||
// Get available rewards based on points
|
||||
const { data: membershipPlans, error: plansError } = await supabaseAdmin
|
||||
.from('membership_plans')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
|
||||
if (plansError) {
|
||||
console.error('Error fetching membership plans:', plansError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
transactions: transactions || [],
|
||||
available_rewards: membershipPlans || []
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/aperture/loyalty:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
95
app/api/cron/detect-no-shows/route.ts
Normal file
95
app/api/cron/detect-no-shows/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
|
||||
/**
|
||||
* @description CRITICAL: Detect and mark no-show bookings (runs every 2 hours)
|
||||
* @param {NextRequest} request - Must include Bearer token with CRON_SECRET
|
||||
* @returns {NextResponse} No-show detection results with count of bookings processed
|
||||
* @example curl -H "Authorization: Bearer YOUR_CRON_SECRET" /api/cron/detect-no-shows
|
||||
* @audit BUSINESS RULE: No-show window is 12 hours after booking start time (UTC)
|
||||
* @audit SECURITY: Requires CRON_SECRET environment variable for authentication
|
||||
* @audit Validate: Only confirmed/pending bookings without check-in are affected
|
||||
* @audit AUDIT: Detection action logged in audit_logs with booking details
|
||||
* @audit PERFORMANCE: Efficient query with date range and status filters
|
||||
* @audit RELIABILITY: Cron job should run every 2 hours to detect no-shows
|
||||
*/
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const cronKey = authHeader.replace('Bearer ', '').trim()
|
||||
|
||||
if (cronKey !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid cron key' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate no-show window: bookings that started more than 12 hours ago
|
||||
const windowStart = new Date()
|
||||
windowStart.setHours(windowStart.getHours() - 12)
|
||||
|
||||
// Get eligible bookings (confirmed/pending, no check-in, started > 12h ago)
|
||||
const { data: bookings, error: bookingsError } = await supabaseAdmin
|
||||
.from('bookings')
|
||||
.select('id, start_time_utc, customer_id, service_id, deposit_amount')
|
||||
.in('status', ['confirmed', 'pending'])
|
||||
.lt('start_time_utc', windowStart.toISOString())
|
||||
.is('check_in_time', null)
|
||||
|
||||
if (bookingsError) {
|
||||
console.error('Error fetching bookings for no-show detection:', bookingsError)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch bookings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!bookings || bookings.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No bookings to process',
|
||||
processedCount: 0,
|
||||
detectedCount: 0
|
||||
})
|
||||
}
|
||||
|
||||
let detectedCount = 0
|
||||
|
||||
// Process each booking
|
||||
for (const booking of bookings) {
|
||||
const detected = await supabaseAdmin.rpc('detect_no_show_booking', {
|
||||
p_booking_id: booking.id
|
||||
})
|
||||
|
||||
if (detected) {
|
||||
detectedCount++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`No-show detection completed: ${detectedCount} bookings detected out of ${bookings.length} processed`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No-show detection completed successfully',
|
||||
processedCount: bookings.length,
|
||||
detectedCount
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in no-show detection:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
106
app/api/webhooks/stripe/route.ts
Normal file
106
app/api/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { supabaseAdmin } from '@/lib/supabase/admin'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
/**
|
||||
* @description Handle Stripe webhooks for payment intents and refunds
|
||||
* @param {NextRequest} request - Raw Stripe webhook payload with signature
|
||||
* @returns {NextResponse} Webhook processing result
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
|
||||
if (!stripeSecretKey || !stripeWebhookSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey)
|
||||
|
||||
const body = await request.text()
|
||||
const signature = request.headers.get('stripe-signature')
|
||||
|
||||
if (!signature) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Stripe signature' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
let event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
stripeWebhookSecret
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid signature' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const eventId = event.id
|
||||
|
||||
// Check if event already processed
|
||||
const { data: existingLog } = await supabaseAdmin
|
||||
.from('webhook_logs')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.single()
|
||||
|
||||
if (existingLog) {
|
||||
console.log(`Event ${eventId} already processed, skipping`)
|
||||
return NextResponse.json({ received: true, already_processed: true })
|
||||
}
|
||||
|
||||
// Log webhook event
|
||||
await supabaseAdmin.from('webhook_logs').insert({
|
||||
event_type: event.type,
|
||||
event_id: eventId,
|
||||
payload: event.data as any
|
||||
})
|
||||
|
||||
// Process based on event type
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
await supabaseAdmin.rpc('process_payment_intent_succeeded', {
|
||||
p_event_id: eventId,
|
||||
p_payload: event.data as any
|
||||
})
|
||||
break
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
await supabaseAdmin.rpc('process_payment_intent_failed', {
|
||||
p_event_id: eventId,
|
||||
p_payload: event.data as any
|
||||
})
|
||||
break
|
||||
|
||||
case 'charge.refunded':
|
||||
await supabaseAdmin.rpc('process_charge_refunded', {
|
||||
p_event_id: eventId,
|
||||
p_payload: event.data as any
|
||||
})
|
||||
break
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (error) {
|
||||
console.error('Error processing Stripe webhook:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook processing failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user