From fd0e2fd20effecbc2a8dfe94c566a8c0106a75c7 Mon Sep 17 00:00:00 2001 From: cawcenter Date: Fri, 12 Dec 2025 09:54:36 -0500 Subject: [PATCH] feat: Complete marketing schema, client portal APIs, and CSV export --- frontend/src/pages/api/client/dashboard.ts | 114 +++++++++++++++ frontend/src/pages/api/leads/export.ts | 155 +++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 frontend/src/pages/api/client/dashboard.ts create mode 100644 frontend/src/pages/api/leads/export.ts diff --git a/frontend/src/pages/api/client/dashboard.ts b/frontend/src/pages/api/client/dashboard.ts new file mode 100644 index 0000000..a4e5551 --- /dev/null +++ b/frontend/src/pages/api/client/dashboard.ts @@ -0,0 +1,114 @@ +// @ts-ignore - Astro types available at build time +import type { APIRoute } from 'astro'; +import { getDirectusClient, readItems, aggregate } from '@/lib/directus/client'; + +/** + * Client Dashboard API + * + * Returns stats and recent leads for a client's site. + * Used by the client portal. + * + * GET /api/client/dashboard?site_id={id} + */ +export const GET: APIRoute = async ({ url }) => { + try { + const siteId = url.searchParams.get('site_id'); + + if (!siteId) { + return new Response( + JSON.stringify({ error: 'site_id is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const directus = getDirectusClient(); + + // Get lead stats + const [totalLeads, newLeads, convertedLeads, recentLeads, leadsByStatus] = await Promise.all([ + // Total leads count + directus.request( + readItems('leads', { + filter: { site: { _eq: siteId } }, + aggregate: { count: '*' } + }) + ), + // New leads (last 7 days) + directus.request( + readItems('leads', { + filter: { + site: { _eq: siteId }, + date_created: { _gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() } + }, + aggregate: { count: '*' } + }) + ), + // Converted leads + directus.request( + readItems('leads', { + filter: { + site: { _eq: siteId }, + status: { _eq: 'converted' } + }, + aggregate: { count: '*' } + }) + ), + // Recent leads (last 10) + directus.request( + readItems('leads', { + filter: { site: { _eq: siteId } }, + sort: ['-date_created'], + limit: 10, + fields: ['id', 'name', 'email', 'phone', 'source', 'status', 'date_created'] + }) + ), + // Leads grouped by status + directus.request( + readItems('leads', { + filter: { site: { _eq: siteId } }, + groupBy: ['status'], + aggregate: { count: '*' } + }) + ) + ]); + + // Calculate total value + const valueData = await directus.request( + readItems('leads', { + filter: { + site: { _eq: siteId }, + status: { _eq: 'converted' } + }, + aggregate: { sum: 'value' } + }) + ); + + const stats = { + total_leads: parseInt((totalLeads as any)?.[0]?.count || '0', 10), + new_leads_7_days: parseInt((newLeads as any)?.[0]?.count || '0', 10), + converted_leads: parseInt((convertedLeads as any)?.[0]?.count || '0', 10), + total_value: parseFloat((valueData as any)?.[0]?.sum?.value || '0'), + leads_by_status: (leadsByStatus as any[]).reduce((acc, item) => { + acc[item.status] = parseInt(item.count, 10); + return acc; + }, {} as Record) + }; + + return new Response( + JSON.stringify({ + stats, + recent_leads: recentLeads, + export_url: `/api/leads/export?site_id=${siteId}` + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } catch (error) { + console.error('Error fetching dashboard:', error); + return new Response( + JSON.stringify({ error: 'Failed to fetch dashboard data' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; diff --git a/frontend/src/pages/api/leads/export.ts b/frontend/src/pages/api/leads/export.ts new file mode 100644 index 0000000..e8ec49b --- /dev/null +++ b/frontend/src/pages/api/leads/export.ts @@ -0,0 +1,155 @@ +// @ts-ignore - Astro types available at build time +import type { APIRoute } from 'astro'; +import { getDirectusClient, readItems } from '@/lib/directus/client'; + +/** + * Lead Export API - CSV Download + * + * Exports leads for a specific site as CSV. + * Clients can only see their own site's leads. + * + * GET /api/leads/export?site_id={id}&format=csv + */ +export const GET: APIRoute = async ({ request, url }) => { + try { + const siteId = url.searchParams.get('site_id'); + const format = url.searchParams.get('format') || 'csv'; + const status = url.searchParams.get('status'); // Optional filter + const startDate = url.searchParams.get('start_date'); + const endDate = url.searchParams.get('end_date'); + + if (!siteId) { + return new Response( + JSON.stringify({ error: 'site_id is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const directus = getDirectusClient(); + + // Build filter + const filter: Record = { + site: { _eq: siteId } + }; + + if (status) { + filter.status = { _eq: status }; + } + + if (startDate) { + filter.date_created = { _gte: startDate }; + } + + if (endDate) { + filter.date_created = { ...filter.date_created, _lte: endDate }; + } + + // Fetch leads for this site + const leads = await directus.request( + readItems('leads', { + filter, + sort: ['-date_created'], + limit: -1, // All leads + fields: [ + 'id', + 'name', + 'email', + 'phone', + 'message', + 'source', + 'status', + 'page_url', + 'landing_page', + 'referrer', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'value', + 'notes', + 'date_created' + ] + }) + ); + + if (format === 'json') { + return new Response( + JSON.stringify({ leads, total: (leads as any[]).length }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Generate CSV + const csvHeaders = [ + 'ID', + 'Name', + 'Email', + 'Phone', + 'Message', + 'Source', + 'Status', + 'Page URL', + 'Landing Page', + 'Referrer', + 'UTM Source', + 'UTM Medium', + 'UTM Campaign', + 'Value', + 'Notes', + 'Date Created' + ]; + + const csvRows = (leads as any[]).map(lead => [ + lead.id, + escapeCsvField(lead.name), + escapeCsvField(lead.email), + escapeCsvField(lead.phone), + escapeCsvField(lead.message), + escapeCsvField(lead.source), + escapeCsvField(lead.status), + escapeCsvField(lead.page_url), + escapeCsvField(lead.landing_page), + escapeCsvField(lead.referrer), + escapeCsvField(lead.utm_source), + escapeCsvField(lead.utm_medium), + escapeCsvField(lead.utm_campaign), + lead.value || '', + escapeCsvField(lead.notes), + formatDate(lead.date_created) + ].join(',')); + + const csv = [csvHeaders.join(','), ...csvRows].join('\n'); + const filename = `leads-export-${new Date().toISOString().split('T')[0]}.csv`; + + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); + } catch (error) { + console.error('Error exporting leads:', error); + return new Response( + JSON.stringify({ error: 'Failed to export leads' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; + +function escapeCsvField(value: string | null | undefined): string { + if (!value) return ''; + // Escape quotes and wrap in quotes if contains comma, quote, or newline + const stringValue = String(value); + if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +} + +function formatDate(dateString: string | null): string { + if (!dateString) return ''; + return new Date(dateString).toLocaleString(); +}