diff --git a/api/corp/escrow.ts b/api/corp/escrow.ts new file mode 100644 index 00000000..d1357a5e --- /dev/null +++ b/api/corp/escrow.ts @@ -0,0 +1,124 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + if (req.method === 'GET') { + const { contract_id } = req.query; + + let query = supabase + .from('nexus_escrow_ledger') + .select('*') + .or(`client_id.eq.${user.id},creator_id.eq.${user.id}`); + + if (contract_id) { + query = query.eq('contract_id', contract_id); + } + + const { data, error } = await query.order('created_at', { ascending: false }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + if (req.method === 'POST') { + const { contract_id, amount } = req.body; + + if (!contract_id || !amount) { + return res.status(400).json({ error: 'contract_id and amount required' }); + } + + const { data: contract } = await supabase + .from('nexus_contracts') + .select('id, client_id, creator_id, status') + .eq('id', contract_id) + .single(); + + if (!contract) { + return res.status(404).json({ error: 'Contract not found' }); + } + + if (contract.client_id !== user.id) { + return res.status(403).json({ error: 'Only the client can fund escrow' }); + } + + const { data: existing } = await supabase + .from('nexus_escrow_ledger') + .select('id, escrow_balance, funds_deposited') + .eq('contract_id', contract_id) + .single(); + + if (existing) { + const { data, error } = await supabase + .from('nexus_escrow_ledger') + .update({ + escrow_balance: existing.escrow_balance + amount, + funds_deposited: existing.funds_deposited + amount, + status: 'funded', + funded_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }) + .eq('id', existing.id) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + const { data, error } = await supabase + .from('nexus_escrow_ledger') + .insert({ + contract_id: contract_id, + client_id: contract.client_id, + creator_id: contract.creator_id, + escrow_balance: amount, + funds_deposited: amount, + status: 'funded', + funded_at: new Date().toISOString() + }) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'escrow', + entity_id: data.id, + event_type: 'escrow_funded', + event_category: 'financial', + actor_id: user.id, + actor_role: 'client', + realm_context: 'corp', + description: `Escrow funded with $${amount}`, + payload: { contract_id, amount }, + financial_amount: amount, + legal_entity: 'for_profit' + }); + + return res.status(201).json({ data }); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/api/corp/payroll.ts b/api/corp/payroll.ts new file mode 100644 index 00000000..66a2ce00 --- /dev/null +++ b/api/corp/payroll.ts @@ -0,0 +1,143 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + const { data: userProfile } = await supabase + .from('user_profiles') + .select('user_type') + .eq('id', user.id) + .single(); + + if (userProfile?.user_type !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + + if (req.method === 'GET') { + const { status, start_date, end_date, tax_year } = req.query; + + let query = supabase + .from('nexus_payouts') + .select(` + *, + nexus_talent_profiles!inner( + user_id, + legal_first_name, + legal_last_name, + tax_classification, + residency_state + ) + `) + .order('created_at', { ascending: false }); + + if (status) query = query.eq('status', status); + if (tax_year) query = query.eq('tax_year', tax_year); + if (start_date) query = query.gte('scheduled_date', start_date); + if (end_date) query = query.lte('scheduled_date', end_date); + + const { data, error } = await query; + + if (error) { + return res.status(500).json({ error: error.message }); + } + + const totalPending = data?.filter(p => p.status === 'pending').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0; + const totalProcessed = data?.filter(p => p.status === 'completed').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0; + + return res.status(200).json({ + data, + summary: { + total_payouts: data?.length || 0, + pending_amount: totalPending, + processed_amount: totalProcessed + } + }); + } + + if (req.method === 'POST' && req.query.action === 'process') { + const { payout_ids } = req.body; + + if (!payout_ids || !Array.isArray(payout_ids)) { + return res.status(400).json({ error: 'payout_ids array required' }); + } + + const { data: payouts } = await supabase + .from('nexus_payouts') + .select('*') + .in('id', payout_ids) + .eq('status', 'pending'); + + if (!payouts || payouts.length === 0) { + return res.status(400).json({ error: 'No pending payouts found' }); + } + + const { data, error } = await supabase + .from('nexus_payouts') + .update({ + status: 'processing', + updated_at: new Date().toISOString() + }) + .in('id', payout_ids) + .select(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'payroll', + entity_id: user.id, + event_type: 'payroll_batch_processing', + event_category: 'financial', + actor_id: user.id, + actor_role: 'admin', + realm_context: 'corp', + description: `Processing ${data?.length} payouts`, + payload: { payout_ids, total_amount: data?.reduce((sum, p) => sum + Number(p.net_amount), 0) }, + legal_entity: 'for_profit' + }); + + return res.status(200).json({ + data, + processed_count: data?.length || 0 + }); + } + + if (req.method === 'GET' && req.query.action === 'summary') { + const currentYear = new Date().getFullYear(); + + const { data: yearPayouts } = await supabase + .from('nexus_payouts') + .select('net_amount, status, tax_year') + .eq('tax_year', currentYear); + + const { data: azHours } = await supabase + .from('nexus_time_logs') + .select('az_eligible_hours') + .eq('submission_status', 'approved') + .gte('log_date', `${currentYear}-01-01`); + + return res.status(200).json({ + tax_year: currentYear, + total_payouts: yearPayouts?.filter(p => p.status === 'completed').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0, + pending_payouts: yearPayouts?.filter(p => p.status === 'pending').reduce((sum, p) => sum + Number(p.net_amount), 0) || 0, + total_az_hours: azHours?.reduce((sum, h) => sum + Number(h.az_eligible_hours), 0) || 0, + payout_count: yearPayouts?.length || 0 + }); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/api/foundation/gig-radar.ts b/api/foundation/gig-radar.ts new file mode 100644 index 00000000..1fabdd66 --- /dev/null +++ b/api/foundation/gig-radar.ts @@ -0,0 +1,78 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + const { category, skills, experience, limit = 20, offset = 0 } = req.query; + + const { data, error } = await supabase + .from('foundation_gig_radar') + .select('*') + .order('published_at', { ascending: false }) + .range(Number(offset), Number(offset) + Number(limit) - 1); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + let filteredData = data || []; + + if (category) { + filteredData = filteredData.filter(d => d.category === category); + } + + if (skills) { + const skillsArray = (skills as string).split(','); + filteredData = filteredData.filter(d => + d.required_skills.some((s: string) => skillsArray.includes(s)) + ); + } + + if (experience) { + filteredData = filteredData.filter(d => d.required_experience === experience); + } + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'gig_radar', + entity_id: user.id, + event_type: 'gig_radar_accessed', + event_category: 'access', + actor_id: user.id, + actor_role: 'user', + realm_context: 'foundation', + description: 'Foundation user accessed Gig Radar', + payload: { + filters: { category, skills, experience }, + results_count: filteredData.length + }, + sensitive_data_accessed: false, + cross_entity_access: true, + legal_entity: 'non_profit' + }); + + return res.status(200).json({ + data: filteredData, + meta: { + total: filteredData.length, + limit: Number(limit), + offset: Number(offset) + } + }); +} diff --git a/api/nexus-core/talent-profiles.ts b/api/nexus-core/talent-profiles.ts new file mode 100644 index 00000000..4b640b47 --- /dev/null +++ b/api/nexus-core/talent-profiles.ts @@ -0,0 +1,84 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + if (req.method === 'GET') { + const { data, error } = await supabase + .from('nexus_talent_profiles') + .select('*') + .eq('user_id', user.id) + .single(); + + if (error && error.code !== 'PGRST116') { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + if (req.method === 'POST') { + const body = req.body; + + const { data, error } = await supabase + .from('nexus_talent_profiles') + .upsert({ + user_id: user.id, + legal_first_name: body.legal_first_name, + legal_last_name: body.legal_last_name, + tax_classification: body.tax_classification, + residency_state: body.residency_state, + residency_country: body.residency_country || 'US', + address_city: body.address_city, + address_state: body.address_state, + address_zip: body.address_zip, + updated_at: new Date().toISOString() + }, { onConflict: 'user_id' }) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'talent', + entity_id: data.id, + event_type: 'profile_updated', + event_category: 'data_change', + actor_id: user.id, + actor_role: 'talent', + realm_context: 'nexus', + description: 'Talent profile updated', + payload: { fields_updated: Object.keys(body) } + }); + + return res.status(200).json({ data }); + } + + if (req.method === 'GET' && req.query.action === 'compliance-summary') { + const { data, error } = await supabase + .rpc('get_talent_compliance_summary', { p_user_id: user.id }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/api/nexus-core/time-logs-approve.ts b/api/nexus-core/time-logs-approve.ts new file mode 100644 index 00000000..10a0814a --- /dev/null +++ b/api/nexus-core/time-logs-approve.ts @@ -0,0 +1,102 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + const { data: userProfile } = await supabase + .from('user_profiles') + .select('user_type') + .eq('id', user.id) + .single(); + + const { time_log_id, decision, notes } = req.body; + + if (!time_log_id || !decision) { + return res.status(400).json({ error: 'time_log_id and decision required' }); + } + + if (!['approved', 'rejected', 'needs_correction'].includes(decision)) { + return res.status(400).json({ error: 'Invalid decision. Must be: approved, rejected, or needs_correction' }); + } + + const { data: timeLog } = await supabase + .from('nexus_time_logs') + .select('*, nexus_contracts!inner(client_id)') + .eq('id', time_log_id) + .single(); + + if (!timeLog) { + return res.status(404).json({ error: 'Time log not found' }); + } + + const isClient = timeLog.nexus_contracts?.client_id === user.id; + const isAdmin = userProfile?.user_type === 'admin'; + + if (!isClient && !isAdmin) { + return res.status(403).json({ error: 'Only the contract client or admin can approve time logs' }); + } + + if (timeLog.submission_status !== 'submitted') { + return res.status(400).json({ error: 'Time log must be in submitted status to approve/reject' }); + } + + const newStatus = decision === 'approved' ? 'approved' : + decision === 'rejected' ? 'rejected' : 'rejected'; + + const { data, error } = await supabase + .from('nexus_time_logs') + .update({ + submission_status: newStatus, + approved_at: decision === 'approved' ? new Date().toISOString() : null, + approved_by: decision === 'approved' ? user.id : null, + updated_at: new Date().toISOString() + }) + .eq('id', time_log_id) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + await supabase.from('nexus_time_log_audits').insert({ + time_log_id: time_log_id, + reviewer_id: user.id, + audit_type: decision === 'approved' ? 'approval' : 'rejection', + decision: decision, + notes: notes, + ip_address: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress, + user_agent: req.headers['user-agent'] + }); + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'time_log', + entity_id: time_log_id, + event_type: `time_log_${decision}`, + event_category: 'compliance', + actor_id: user.id, + actor_role: isAdmin ? 'admin' : 'client', + realm_context: 'nexus', + description: `Time log ${decision} by ${isAdmin ? 'admin' : 'client'}`, + payload: { decision, notes } + }); + + return res.status(200).json({ data }); +} diff --git a/api/nexus-core/time-logs-submit.ts b/api/nexus-core/time-logs-submit.ts new file mode 100644 index 00000000..81dd8080 --- /dev/null +++ b/api/nexus-core/time-logs-submit.ts @@ -0,0 +1,104 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + const { data: talentProfile } = await supabase + .from('nexus_talent_profiles') + .select('id') + .eq('user_id', user.id) + .single(); + + if (!talentProfile) { + return res.status(400).json({ error: 'Talent profile not found' }); + } + + const { time_log_ids } = req.body; + + if (!time_log_ids || !Array.isArray(time_log_ids) || time_log_ids.length === 0) { + return res.status(400).json({ error: 'time_log_ids array required' }); + } + + const { data: logs, error: fetchError } = await supabase + .from('nexus_time_logs') + .select('id, submission_status') + .in('id', time_log_ids) + .eq('talent_profile_id', talentProfile.id); + + if (fetchError) { + return res.status(500).json({ error: fetchError.message }); + } + + const invalidLogs = logs?.filter(l => l.submission_status !== 'draft' && l.submission_status !== 'rejected'); + if (invalidLogs && invalidLogs.length > 0) { + return res.status(400).json({ + error: 'Some time logs cannot be submitted', + invalid_ids: invalidLogs.map(l => l.id) + }); + } + + const validIds = logs?.map(l => l.id) || []; + if (validIds.length === 0) { + return res.status(400).json({ error: 'No valid time logs found' }); + } + + const { data, error } = await supabase + .from('nexus_time_logs') + .update({ + submission_status: 'submitted', + submitted_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }) + .in('id', validIds) + .select(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + for (const log of data || []) { + await supabase.from('nexus_time_log_audits').insert({ + time_log_id: log.id, + reviewer_id: null, + audit_type: 'review', + decision: 'submitted', + notes: 'Time log submitted for review', + ip_address: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress, + user_agent: req.headers['user-agent'] + }); + } + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'time_log', + entity_id: talentProfile.id, + event_type: 'batch_submitted', + event_category: 'compliance', + actor_id: user.id, + actor_role: 'talent', + realm_context: 'nexus', + description: `Submitted ${data?.length} time logs for review`, + payload: { time_log_ids: validIds } + }); + + return res.status(200).json({ + data, + submitted_count: data?.length || 0 + }); +} diff --git a/api/nexus-core/time-logs.ts b/api/nexus-core/time-logs.ts new file mode 100644 index 00000000..4770fc89 --- /dev/null +++ b/api/nexus-core/time-logs.ts @@ -0,0 +1,170 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const supabase = getAdminClient(); + + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + const { data: { user }, error: authError } = await supabase.auth.getUser(token); + + if (authError || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + const { data: talentProfile } = await supabase + .from('nexus_talent_profiles') + .select('id, az_eligible') + .eq('user_id', user.id) + .single(); + + if (!talentProfile) { + return res.status(400).json({ error: 'Talent profile not found. Create one first.' }); + } + + if (req.method === 'GET') { + const { contract_id, start_date, end_date, status } = req.query; + + let query = supabase + .from('nexus_time_logs') + .select('*') + .eq('talent_profile_id', talentProfile.id) + .order('log_date', { ascending: false }); + + if (contract_id) query = query.eq('contract_id', contract_id); + if (start_date) query = query.gte('log_date', start_date); + if (end_date) query = query.lte('log_date', end_date); + if (status) query = query.eq('submission_status', status); + + const { data, error } = await query; + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + if (req.method === 'POST') { + const body = req.body; + + const azEligibleHours = body.location_state === 'AZ' && talentProfile.az_eligible + ? body.hours_worked + : 0; + + const { data, error } = await supabase + .from('nexus_time_logs') + .insert({ + talent_profile_id: talentProfile.id, + contract_id: body.contract_id, + milestone_id: body.milestone_id, + log_date: body.log_date, + start_time: body.start_time, + end_time: body.end_time, + hours_worked: body.hours_worked, + description: body.description, + task_category: body.task_category, + location_type: body.location_type || 'remote', + location_state: body.location_state, + location_city: body.location_city, + location_latitude: body.location_latitude, + location_longitude: body.location_longitude, + az_eligible_hours: azEligibleHours, + billable: body.billable !== false, + submission_status: 'draft' + }) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(201).json({ data }); + } + + if (req.method === 'PUT') { + const { id } = req.query; + const body = req.body; + + if (!id) { + return res.status(400).json({ error: 'Time log ID required' }); + } + + const { data: existingLog } = await supabase + .from('nexus_time_logs') + .select('*') + .eq('id', id) + .eq('talent_profile_id', talentProfile.id) + .single(); + + if (!existingLog) { + return res.status(404).json({ error: 'Time log not found' }); + } + + if (existingLog.submission_status !== 'draft' && existingLog.submission_status !== 'rejected') { + return res.status(400).json({ error: 'Can only edit draft or rejected time logs' }); + } + + const azEligibleHours = (body.location_state || existingLog.location_state) === 'AZ' && talentProfile.az_eligible + ? (body.hours_worked || existingLog.hours_worked) + : 0; + + const { data, error } = await supabase + .from('nexus_time_logs') + .update({ + ...body, + az_eligible_hours: azEligibleHours, + updated_at: new Date().toISOString() + }) + .eq('id', id) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + if (req.method === 'DELETE') { + const { id } = req.query; + + if (!id) { + return res.status(400).json({ error: 'Time log ID required' }); + } + + const { data: existingLog } = await supabase + .from('nexus_time_logs') + .select('submission_status') + .eq('id', id) + .eq('talent_profile_id', talentProfile.id) + .single(); + + if (!existingLog) { + return res.status(404).json({ error: 'Time log not found' }); + } + + if (existingLog.submission_status !== 'draft') { + return res.status(400).json({ error: 'Can only delete draft time logs' }); + } + + const { error } = await supabase + .from('nexus_time_logs') + .delete() + .eq('id', id); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(204).end(); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/api/studio/contracts.ts b/api/studio/contracts.ts new file mode 100644 index 00000000..426d27c4 --- /dev/null +++ b/api/studio/contracts.ts @@ -0,0 +1,86 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +const STUDIO_API_KEY = process.env.STUDIO_API_KEY; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const supabase = getAdminClient(); + + const apiKey = req.headers['x-studio-api-key'] || req.headers['authorization']?.replace('Bearer ', ''); + + const authHeader = req.headers.authorization; + let userId: string | null = null; + let isServiceAuth = false; + + if (apiKey === STUDIO_API_KEY && STUDIO_API_KEY) { + isServiceAuth = true; + } else if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1]; + const { data: { user }, error } = await supabase.auth.getUser(token); + if (error || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + userId = user.id; + } else { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { id } = req.query; + + if (id) { + const { data, error } = await supabase + .from('nexus_contracts') + .select(` + id, title, status, contract_type, + start_date, end_date, + creator_id, client_id + `) + .eq('id', id) + .single(); + + if (error) { + return res.status(error.code === 'PGRST116' ? 404 : 500).json({ error: error.message }); + } + + if (!isServiceAuth && data.creator_id !== userId && data.client_id !== userId) { + return res.status(403).json({ error: 'Access denied' }); + } + + return res.status(200).json({ + data: { + id: data.id, + title: data.title, + status: data.status, + contract_type: data.contract_type, + start_date: data.start_date, + end_date: data.end_date, + is_creator: data.creator_id === userId, + is_client: data.client_id === userId + } + }); + } + + if (!userId && !isServiceAuth) { + return res.status(400).json({ error: 'user_id required for listing contracts' }); + } + + const { data, error } = await supabase + .from('nexus_contracts') + .select(` + id, title, status, contract_type, + start_date, end_date + `) + .or(`creator_id.eq.${userId},client_id.eq.${userId}`) + .in('status', ['active', 'pending']) + .order('created_at', { ascending: false }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); +} diff --git a/api/studio/time-logs.ts b/api/studio/time-logs.ts new file mode 100644 index 00000000..ffeea937 --- /dev/null +++ b/api/studio/time-logs.ts @@ -0,0 +1,142 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { getAdminClient } from "../_supabase"; + +const STUDIO_API_KEY = process.env.STUDIO_API_KEY; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const supabase = getAdminClient(); + + const apiKey = req.headers['x-studio-api-key'] || req.headers['authorization']?.replace('Bearer ', ''); + + const authHeader = req.headers.authorization; + let userId: string | null = null; + let isServiceAuth = false; + + if (apiKey === STUDIO_API_KEY && STUDIO_API_KEY) { + isServiceAuth = true; + } else if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1]; + const { data: { user }, error } = await supabase.auth.getUser(token); + if (error || !user) { + return res.status(401).json({ error: 'Invalid token' }); + } + userId = user.id; + } else { + return res.status(401).json({ error: 'Unauthorized - requires Bearer token or X-Studio-API-Key' }); + } + + if (req.method === 'POST') { + const body = req.body; + + if (!body.user_id && !userId) { + return res.status(400).json({ error: 'user_id required for service auth' }); + } + + const targetUserId = body.user_id || userId; + + const { data: talentProfile } = await supabase + .from('nexus_talent_profiles') + .select('id, az_eligible') + .eq('user_id', targetUserId) + .single(); + + if (!talentProfile) { + return res.status(400).json({ error: 'Talent profile not found for user' }); + } + + const azEligibleHours = body.location_state === 'AZ' && talentProfile.az_eligible + ? body.hours_worked + : 0; + + const { data, error } = await supabase + .from('nexus_time_logs') + .insert({ + talent_profile_id: talentProfile.id, + contract_id: body.contract_id, + log_date: body.log_date, + start_time: body.start_time, + end_time: body.end_time, + hours_worked: body.hours_worked, + description: body.description, + task_category: body.task_category, + location_type: body.location_type || 'remote', + location_state: body.location_state, + location_city: body.location_city, + location_latitude: body.location_latitude, + location_longitude: body.location_longitude, + location_verified: !!body.location_latitude && !!body.location_longitude, + az_eligible_hours: azEligibleHours, + billable: body.billable !== false, + submission_status: 'submitted', + submitted_at: new Date().toISOString() + }) + .select() + .single(); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + await supabase.from('nexus_compliance_events').insert({ + entity_type: 'time_log', + entity_id: data.id, + event_type: 'studio_time_log_created', + event_category: 'compliance', + actor_id: isServiceAuth ? null : userId, + actor_role: isServiceAuth ? 'api' : 'talent', + realm_context: 'studio', + description: 'Time log submitted via Studio API', + payload: { + source: 'studio_api', + location_verified: data.location_verified, + az_eligible_hours: azEligibleHours + } + }); + + return res.status(201).json({ data }); + } + + if (req.method === 'GET') { + const { contract_id, start_date, end_date, user_id: queryUserId } = req.query; + + const targetUserId = queryUserId || userId; + + if (!targetUserId && !isServiceAuth) { + return res.status(400).json({ error: 'user_id required' }); + } + + let query = supabase + .from('nexus_time_logs') + .select(` + id, log_date, start_time, end_time, hours_worked, + location_state, az_eligible_hours, submission_status, + contract_id + `); + + if (targetUserId) { + const { data: talentProfile } = await supabase + .from('nexus_talent_profiles') + .select('id') + .eq('user_id', targetUserId) + .single(); + + if (talentProfile) { + query = query.eq('talent_profile_id', talentProfile.id); + } + } + + if (contract_id) query = query.eq('contract_id', contract_id); + if (start_date) query = query.gte('log_date', start_date); + if (end_date) query = query.lte('log_date', end_date); + + const { data, error } = await query.order('log_date', { ascending: false }); + + if (error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(200).json({ data }); + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/client/components/nexus-core/ComplianceSummaryCard.tsx b/client/components/nexus-core/ComplianceSummaryCard.tsx new file mode 100644 index 00000000..b93dc93c --- /dev/null +++ b/client/components/nexus-core/ComplianceSummaryCard.tsx @@ -0,0 +1,135 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Shield, + CheckCircle, + AlertCircle, + Clock, + FileText, + CreditCard, + MapPin, +} from "lucide-react"; +import type { TalentComplianceSummary, ComplianceStatus } from "@/lib/nexus-core-types"; + +interface ComplianceSummaryCardProps { + summary: TalentComplianceSummary; + onCompleteProfile?: () => void; + onSubmitW9?: () => void; + onConnectBank?: () => void; +} + +const statusConfig: Record = { + pending: { color: "bg-amber-500/20 text-amber-300 border-amber-500/30", icon: , label: "Pending Review" }, + verified: { color: "bg-green-500/20 text-green-300 border-green-500/30", icon: , label: "Verified" }, + expired: { color: "bg-red-500/20 text-red-300 border-red-500/30", icon: , label: "Expired" }, + rejected: { color: "bg-red-500/20 text-red-300 border-red-500/30", icon: , label: "Rejected" }, + review_needed: { color: "bg-amber-500/20 text-amber-300 border-amber-500/30", icon: , label: "Review Needed" }, +}; + +export function ComplianceSummaryCard({ + summary, + onCompleteProfile, + onSubmitW9, + onConnectBank, +}: ComplianceSummaryCardProps) { + const status = statusConfig[summary.compliance_status]; + + const completionSteps = [ + { done: summary.profile_complete, label: "Profile Complete", action: onCompleteProfile }, + { done: summary.w9_submitted, label: "W-9 Submitted", action: onSubmitW9 }, + { done: summary.bank_connected, label: "Bank Connected", action: onConnectBank }, + ]; + + const completedCount = completionSteps.filter((s) => s.done).length; + const completionPercent = (completedCount / completionSteps.length) * 100; + + return ( + + +
+ + + Compliance Status + + + + {status.icon} + {status.label} + + +
+
+ +
+
+ Setup Progress + {completedCount}/{completionSteps.length} complete +
+ +
+ +
+ {completionSteps.map((step, i) => ( +
+
+ {step.done ? ( + + ) : ( + + )} + + {step.label} + +
+ {!step.done && step.action && ( + + )} +
+ ))} +
+ +
+
+
+ + Hours This Month +
+

{summary.total_hours_this_month}h

+
+
+
+ + AZ Hours +
+

{summary.az_hours_this_month}h

+
+
+ + {summary.pending_time_logs > 0 && ( +
+ + + {summary.pending_time_logs} time log(s) pending review + +
+ )} + + {summary.az_eligible && ( + + + AZ Tax Credit Eligible + + )} +
+
+ ); +} diff --git a/client/components/nexus-core/ContractOverviewCard.tsx b/client/components/nexus-core/ContractOverviewCard.tsx new file mode 100644 index 00000000..b5045b99 --- /dev/null +++ b/client/components/nexus-core/ContractOverviewCard.tsx @@ -0,0 +1,157 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + FileText, + Clock, + DollarSign, + Calendar, + User, + Building, + ArrowRight, +} from "lucide-react"; + +interface ContractOverviewCardProps { + contract: { + id: string; + title: string; + status: string; + contract_type: string; + total_amount: number; + creator_payout_amount: number; + aethex_commission_amount: number; + start_date?: string; + end_date?: string; + milestone_count: number; + completed_milestones?: number; + }; + role: "creator" | "client"; + onViewDetails?: () => void; + onViewTimeLogs?: () => void; +} + +const statusColors: Record = { + pending: "bg-amber-500/20 text-amber-300 border-amber-500/30", + active: "bg-green-500/20 text-green-300 border-green-500/30", + completed: "bg-blue-500/20 text-blue-300 border-blue-500/30", + disputed: "bg-red-500/20 text-red-300 border-red-500/30", + cancelled: "bg-gray-500/20 text-gray-300 border-gray-500/30", +}; + +export function ContractOverviewCard({ + contract, + role, + onViewDetails, + onViewTimeLogs, +}: ContractOverviewCardProps) { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString([], { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const completedMilestones = contract.completed_milestones || 0; + const milestoneProgress = contract.milestone_count > 0 + ? (completedMilestones / contract.milestone_count) * 100 + : 0; + + return ( + + +
+
+ + {contract.title} + +
+ + {contract.contract_type.replace("_", " ")} + + + {contract.status} + +
+
+
+

+ {formatCurrency(role === "creator" ? contract.creator_payout_amount : contract.total_amount)} +

+

+ {role === "creator" ? "Your Payout" : "Contract Value"} +

+
+
+
+ + {contract.milestone_count > 1 && ( +
+
+ Milestones + {completedMilestones}/{contract.milestone_count} +
+ +
+ )} + +
+ {contract.start_date && ( +
+ + Start: {formatDate(contract.start_date)} +
+ )} + {contract.end_date && ( +
+ + End: {formatDate(contract.end_date)} +
+ )} +
+ + {role === "client" && ( +
+ Platform Fee + + {formatCurrency(contract.aethex_commission_amount)} + +
+ )} + +
+ {onViewDetails && ( + + )} + {onViewTimeLogs && ( + + )} +
+
+
+ ); +} diff --git a/client/components/nexus-core/EscrowStatusCard.tsx b/client/components/nexus-core/EscrowStatusCard.tsx new file mode 100644 index 00000000..75eee6ce --- /dev/null +++ b/client/components/nexus-core/EscrowStatusCard.tsx @@ -0,0 +1,127 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Shield, DollarSign, Lock, Unlock, AlertTriangle } from "lucide-react"; +import type { NexusEscrowLedger } from "@/lib/nexus-core-types"; + +interface EscrowStatusCardProps { + escrow: NexusEscrowLedger; + isClient?: boolean; + onFundEscrow?: () => void; + onReleaseRequest?: () => void; +} + +const statusColors: Record = { + unfunded: "bg-gray-500/20 text-gray-300 border-gray-500/30", + funded: "bg-green-500/20 text-green-300 border-green-500/30", + partially_funded: "bg-amber-500/20 text-amber-300 border-amber-500/30", + released: "bg-blue-500/20 text-blue-300 border-blue-500/30", + disputed: "bg-red-500/20 text-red-300 border-red-500/30", + refunded: "bg-purple-500/20 text-purple-300 border-purple-500/30", +}; + +const statusIcons: Record = { + unfunded: , + funded: , + partially_funded: , + released: , + disputed: , + refunded: , +}; + +export function EscrowStatusCard({ + escrow, + isClient = false, + onFundEscrow, + onReleaseRequest, +}: EscrowStatusCardProps) { + const releasePercent = escrow.funds_deposited > 0 + ? (escrow.funds_released / escrow.funds_deposited) * 100 + : 0; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); + }; + + return ( + + +
+ + + Escrow Account + + + + {statusIcons[escrow.status]} + {escrow.status.replace("_", " ")} + + +
+
+ +
+
+

Balance

+

+ {formatCurrency(escrow.escrow_balance)} +

+
+
+

Total Deposited

+

+ {formatCurrency(escrow.funds_deposited)} +

+
+
+ +
+
+ Released + {formatCurrency(escrow.funds_released)} ({releasePercent.toFixed(0)}%) +
+ +
+ + {escrow.aethex_fees > 0 && ( +
+ Platform Fees + {formatCurrency(escrow.aethex_fees)} +
+ )} + + {escrow.funds_refunded > 0 && ( +
+ Refunded + {formatCurrency(escrow.funds_refunded)} +
+ )} + +
+ {isClient && escrow.status === "unfunded" && onFundEscrow && ( + + )} + {!isClient && escrow.status === "funded" && onReleaseRequest && ( + + )} +
+ + {escrow.funded_at && ( +

+ Funded on {new Date(escrow.funded_at).toLocaleDateString()} +

+ )} +
+
+ ); +} diff --git a/client/components/nexus-core/PayrollRunTable.tsx b/client/components/nexus-core/PayrollRunTable.tsx new file mode 100644 index 00000000..90ed9551 --- /dev/null +++ b/client/components/nexus-core/PayrollRunTable.tsx @@ -0,0 +1,236 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { DollarSign, Play, FileText, AlertCircle, CheckCircle, Clock } from "lucide-react"; +import type { NexusPayout } from "@/lib/nexus-core-types"; + +interface PayrollRunTableProps { + payouts: NexusPayout[]; + onProcessSelected?: (payoutIds: string[]) => void; + onViewDetails?: (payout: NexusPayout) => void; + loading?: boolean; +} + +const statusColors: Record = { + pending: "bg-amber-500/20 text-amber-300 border-amber-500/30", + processing: "bg-blue-500/20 text-blue-300 border-blue-500/30", + completed: "bg-green-500/20 text-green-300 border-green-500/30", + failed: "bg-red-500/20 text-red-300 border-red-500/30", + cancelled: "bg-gray-500/20 text-gray-300 border-gray-500/30", +}; + +const statusIcons: Record = { + pending: , + processing: , + completed: , + failed: , + cancelled: , +}; + +export function PayrollRunTable({ + payouts, + onProcessSelected, + onViewDetails, + loading = false, +}: PayrollRunTableProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount); + }; + + const pendingPayouts = payouts.filter((p) => p.status === "pending"); + const totalPending = pendingPayouts.reduce((sum, p) => sum + p.net_amount, 0); + const selectedPayouts = payouts.filter((p) => selectedIds.has(p.id)); + const selectedTotal = selectedPayouts.reduce((sum, p) => sum + p.net_amount, 0); + + const toggleSelect = (id: string) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const selectAllPending = () => { + setSelectedIds(new Set(pendingPayouts.map((p) => p.id))); + }; + + const clearSelection = () => { + setSelectedIds(new Set()); + }; + + const handleProcess = () => { + if (onProcessSelected && selectedIds.size > 0) { + onProcessSelected(Array.from(selectedIds)); + clearSelection(); + } + }; + + return ( + + +
+ + + Payroll Queue + +
+ + {pendingPayouts.length} pending • {formatCurrency(totalPending)} + +
+
+
+ + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected • Total: {formatCurrency(selectedTotal)} + +
+ + +
+
+ )} + + {pendingPayouts.length > 0 && selectedIds.size === 0 && ( + + )} + +
+ + + + + Talent + Gross + Fees + Net + Method + Status + + + + + {payouts.length === 0 ? ( + + + No payouts in queue + + + ) : ( + payouts.map((payout) => ( + + + {payout.status === "pending" && ( + toggleSelect(payout.id)} + /> + )} + + +
+ Talent #{payout.talent_profile_id.slice(0, 8)} +
+ {payout.contract_id && ( +
+ Contract: {payout.contract_id.slice(0, 8)} +
+ )} +
+ {formatCurrency(payout.gross_amount)} + + -{formatCurrency(payout.platform_fee + payout.processing_fee)} + + + {formatCurrency(payout.net_amount)} + + + + {payout.payout_method} + + + + + + {statusIcons[payout.status]} + {payout.status} + + + + + + +
+ )) + )} +
+
+
+ + {payouts.length > 0 && ( +
+
+

+ {formatCurrency(payouts.filter(p => p.status === "completed").reduce((s, p) => s + p.net_amount, 0))} +

+

Completed

+
+
+

+ {formatCurrency(totalPending)} +

+

Pending

+
+
+

+ {formatCurrency(payouts.filter(p => p.status === "processing").reduce((s, p) => s + p.net_amount, 0))} +

+

Processing

+
+
+ )} +
+
+ ); +} diff --git a/client/components/nexus-core/TimeLogCard.tsx b/client/components/nexus-core/TimeLogCard.tsx new file mode 100644 index 00000000..78e9ccf6 --- /dev/null +++ b/client/components/nexus-core/TimeLogCard.tsx @@ -0,0 +1,138 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Clock, MapPin, CheckCircle, XCircle, Send, Edit, Trash2 } from "lucide-react"; +import type { NexusTimeLog } from "@/lib/nexus-core-types"; + +interface TimeLogCardProps { + timeLog: NexusTimeLog; + onEdit?: () => void; + onDelete?: () => void; + onSubmit?: () => void; + showActions?: boolean; +} + +const statusColors: Record = { + draft: "bg-gray-500/20 text-gray-300 border-gray-500/30", + submitted: "bg-blue-500/20 text-blue-300 border-blue-500/30", + approved: "bg-green-500/20 text-green-300 border-green-500/30", + rejected: "bg-red-500/20 text-red-300 border-red-500/30", + exported: "bg-purple-500/20 text-purple-300 border-purple-500/30", +}; + +const statusIcons: Record = { + draft: , + submitted: , + approved: , + rejected: , + exported: , +}; + +export function TimeLogCard({ + timeLog, + onEdit, + onDelete, + onSubmit, + showActions = true, +}: TimeLogCardProps) { + const formatTime = (time: string) => { + return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString([], { + weekday: "short", + month: "short", + day: "numeric" + }); + }; + + const canEdit = timeLog.submission_status === "draft" || timeLog.submission_status === "rejected"; + const canDelete = timeLog.submission_status === "draft"; + const canSubmit = timeLog.submission_status === "draft" || timeLog.submission_status === "rejected"; + + return ( + + +
+
+
+ + {timeLog.hours_worked}h + + {formatTime(timeLog.start_time)} - {formatTime(timeLog.end_time)} + + + • {formatDate(timeLog.log_date)} + +
+ + {timeLog.description && ( +

{timeLog.description}

+ )} + +
+ {timeLog.location_state && ( + + + {timeLog.location_city && `${timeLog.location_city}, `} + {timeLog.location_state} + + )} + {timeLog.task_category && ( + + {timeLog.task_category} + + )} + {timeLog.az_eligible_hours > 0 && ( + + AZ Eligible: {timeLog.az_eligible_hours}h + + )} + {timeLog.location_verified && ( + + GPS Verified + + )} +
+
+ +
+ + + {statusIcons[timeLog.submission_status]} + {timeLog.submission_status} + + + + {showActions && ( +
+ {canSubmit && onSubmit && ( + + )} + {canEdit && onEdit && ( + + )} + {canDelete && onDelete && ( + + )} +
+ )} +
+
+ + {timeLog.billable && !timeLog.billed && ( +
+ Billable • Not yet invoiced +
+ )} +
+
+ ); +} diff --git a/client/components/nexus-core/index.ts b/client/components/nexus-core/index.ts new file mode 100644 index 00000000..a8600a76 --- /dev/null +++ b/client/components/nexus-core/index.ts @@ -0,0 +1,5 @@ +export { EscrowStatusCard } from "./EscrowStatusCard"; +export { TimeLogCard } from "./TimeLogCard"; +export { PayrollRunTable } from "./PayrollRunTable"; +export { ComplianceSummaryCard } from "./ComplianceSummaryCard"; +export { ContractOverviewCard } from "./ContractOverviewCard"; diff --git a/client/lib/nexus-core-types.ts b/client/lib/nexus-core-types.ts new file mode 100644 index 00000000..48d05046 --- /dev/null +++ b/client/lib/nexus-core-types.ts @@ -0,0 +1,226 @@ +export type TaxClassification = 'w2_employee' | '1099_contractor' | 'corp_entity' | 'foreign'; +export type ComplianceStatus = 'pending' | 'verified' | 'expired' | 'rejected' | 'review_needed'; +export type PayoutMethod = 'stripe' | 'ach' | 'check' | 'paypal'; +export type LocationType = 'remote' | 'onsite' | 'hybrid'; +export type SubmissionStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'exported'; +export type AuditType = 'review' | 'approval' | 'rejection' | 'az_submission' | 'correction' | 'dispute'; +export type AuditDecision = 'approved' | 'rejected' | 'needs_correction' | 'submitted' | 'acknowledged'; +export type AzSubmissionStatus = 'pending' | 'accepted' | 'rejected' | 'error'; +export type EventCategory = 'compliance' | 'financial' | 'access' | 'data_change' | 'tax_reporting' | 'legal'; +export type EscrowStatus = 'unfunded' | 'funded' | 'partially_funded' | 'released' | 'disputed' | 'refunded'; +export type PayoutStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +export interface NexusTalentProfile { + id: string; + user_id: string; + legal_first_name?: string; + legal_last_name?: string; + tax_id_last_four?: string; + tax_classification?: TaxClassification; + residency_state?: string; + residency_country: string; + address_city?: string; + address_state?: string; + address_zip?: string; + compliance_status: ComplianceStatus; + compliance_verified_at?: string; + compliance_expires_at?: string; + az_eligible: boolean; + w9_submitted: boolean; + w9_submitted_at?: string; + bank_account_connected: boolean; + stripe_connect_account_id?: string; + payout_method?: PayoutMethod; + created_at: string; + updated_at: string; +} + +export interface NexusTimeLog { + id: string; + talent_profile_id: string; + contract_id?: string; + milestone_id?: string; + log_date: string; + start_time: string; + end_time: string; + hours_worked: number; + description?: string; + task_category?: string; + location_type: LocationType; + location_state?: string; + location_city?: string; + location_latitude?: number; + location_longitude?: number; + location_verified: boolean; + az_eligible_hours: number; + billable: boolean; + billed: boolean; + billed_at?: string; + invoice_id?: string; + submission_status: SubmissionStatus; + submitted_at?: string; + approved_at?: string; + approved_by?: string; + tax_period?: string; + created_at: string; + updated_at: string; +} + +export interface NexusTimeLogAudit { + id: string; + time_log_id: string; + reviewer_id?: string; + audit_type: AuditType; + decision?: AuditDecision; + notes?: string; + corrections_made?: Record; + az_submission_id?: string; + az_submission_status?: AzSubmissionStatus; + az_submission_response?: Record; + ip_address?: string; + user_agent?: string; + created_at: string; +} + +export interface NexusComplianceEvent { + id: string; + entity_type: string; + entity_id: string; + event_type: string; + event_category: EventCategory; + actor_id?: string; + actor_role?: string; + realm_context?: string; + description?: string; + payload?: Record; + sensitive_data_accessed: boolean; + financial_amount?: number; + legal_entity?: string; + cross_entity_access: boolean; + ip_address?: string; + user_agent?: string; + created_at: string; +} + +export interface NexusEscrowLedger { + id: string; + contract_id: string; + client_id: string; + creator_id: string; + escrow_balance: number; + funds_deposited: number; + funds_released: number; + funds_refunded: number; + aethex_fees: number; + stripe_customer_id?: string; + stripe_escrow_intent_id?: string; + status: EscrowStatus; + funded_at?: string; + released_at?: string; + created_at: string; + updated_at: string; +} + +export interface NexusPayout { + id: string; + talent_profile_id: string; + contract_id?: string; + payment_id?: string; + gross_amount: number; + platform_fee: number; + processing_fee: number; + tax_withholding: number; + net_amount: number; + payout_method: PayoutMethod; + stripe_payout_id?: string; + ach_trace_number?: string; + check_number?: string; + status: PayoutStatus; + scheduled_date?: string; + processed_at?: string; + failure_reason?: string; + tax_year: number; + tax_form_type?: string; + tax_form_generated: boolean; + tax_form_file_id?: string; + created_at: string; + updated_at: string; +} + +export interface FoundationGigRadarItem { + opportunity_id: string; + title: string; + category: string; + required_skills: string[]; + timeline_type: string; + location_requirement: string; + required_experience: string; + status: string; + published_at: string; + availability_status: 'available' | 'in_progress' | 'filled'; + applicant_count: number; + compensation_type: 'hourly' | 'project'; +} + +export interface TalentComplianceSummary { + profile_complete: boolean; + compliance_status: ComplianceStatus; + az_eligible: boolean; + w9_submitted: boolean; + bank_connected: boolean; + pending_time_logs: number; + total_hours_this_month: number; + az_hours_this_month: number; +} + +export interface TimeLogCreateRequest { + contract_id?: string; + milestone_id?: string; + log_date: string; + start_time: string; + end_time: string; + hours_worked: number; + description?: string; + task_category?: string; + location_type: LocationType; + location_state?: string; + location_city?: string; + location_latitude?: number; + location_longitude?: number; +} + +export interface TimeLogSubmitRequest { + time_log_ids: string[]; +} + +export interface TimeLogApproveRequest { + time_log_id: string; + decision: 'approved' | 'rejected' | 'needs_correction'; + notes?: string; +} + +export interface TalentProfileUpdateRequest { + legal_first_name?: string; + legal_last_name?: string; + tax_classification?: TaxClassification; + residency_state?: string; + residency_country?: string; + address_city?: string; + address_state?: string; + address_zip?: string; +} + +export interface AzExportRequest { + start_date: string; + end_date: string; + talent_ids?: string[]; +} + +export interface AzExportResponse { + export_id: string; + total_hours: number; + az_eligible_hours: number; + talent_count: number; + status: 'pending' | 'processing' | 'completed' | 'failed'; + download_url?: string; +} diff --git a/replit.md b/replit.md index e18d8275..20495a85 100644 --- a/replit.md +++ b/replit.md @@ -47,6 +47,42 @@ The monolith (`aethex.dev`) implements split routing to enforce legal separation This ensures the Foundation's user-facing URLs display `aethex.foundation` in the browser, demonstrating operational independence per the Axiom Model. +## NEXUS Core Architecture (Universal Data Layer) +The NEXUS Core serves as the Single Source of Truth for all talent/contract metadata, supporting AZ Tax Commission reporting and legal entity separation. + +### Layer Architecture +| Layer | Function | Legal Significance | +|-------|----------|-------------------| +| **NEXUS Core** (Universal Data Layer) | Single Source of Truth for all talent/contract metadata | Submits verifiable Time Logs to AZ Tax Commission | +| **aethex.dev** (FULL Access UI) | Manages Client Billing, Escrow, and Payroll | The Financial Nexus that claims the Tax Credit and pays the Corp employees | +| **aethex.foundation** (READ-ONLY UI) | Displays "Gig Radar" for student placement | The Legal Firewall—prevents the Non-Profit from seeing Corp client financials | +| **.studio** (API Calls Only) | Secure, direct reporting of employee/contractor hours | Proof of AZ Activity required for the AIC Grant and Tax Credit eligibility | + +### Database Schema (NEXUS Core Tables) +- `nexus_talent_profiles` - Legal/tax info, encrypted PII, compliance status, AZ eligibility +- `nexus_time_logs` - Hour tracking with location, AZ-eligible hours calculation +- `nexus_time_log_audits` - Review decisions, AZ Tax Commission submission tracking +- `nexus_compliance_events` - Cross-entity audit trail for legal separation +- `nexus_escrow_ledger` - Escrow account tracking per contract +- `nexus_payouts` - Payout records with tax form tracking +- `foundation_gig_radar` - Read-only view for Foundation (no financial data exposed) + +### API Endpoints +| Endpoint | Layer | Purpose | +|----------|-------|---------| +| `/api/nexus-core/talent-profiles` | NEXUS Core | Talent legal/tax profile management | +| `/api/nexus-core/time-logs` | NEXUS Core | Time log CRUD operations | +| `/api/nexus-core/time-logs-submit` | NEXUS Core | Batch submit time logs for review | +| `/api/nexus-core/time-logs-approve` | NEXUS Core | Client/admin approval workflow | +| `/api/foundation/gig-radar` | Foundation | Read-only opportunity listings | +| `/api/studio/time-logs` | Studio | Service-to-service hour reporting | +| `/api/studio/contracts` | Studio | Contract status lookup | +| `/api/corp/escrow` | Corp | Escrow funding and management | +| `/api/corp/payroll` | Corp | Payroll processing (admin only) | + +### TypeScript Types +See `client/lib/nexus-core-types.ts` for all NEXUS Core type definitions. + ## Recent Changes (December 2025) - **XP & Leveling System**: Complete XP earning logic with 12 event types (daily_login, profile_complete, create_post, earn_badge, etc.). 1000 XP per level, automatic level-up notifications. Integrated with daily login streak (25 XP + 10 per streak day), profile completion (100 XP), badge earning (200 XP), and post creation (20 XP). Services: `aethexXPService` in database adapter, `useXP` React hook. - **Unified Role/Tier System**: Combines paid subscriptions (Stripe) with earned badges for AI persona access. Tiers: Free/Pro ($9/mo)/Council ($29/mo). Badges unlock specific AI personas. diff --git a/supabase/migrations/20251213_add_nexus_core_schema.sql b/supabase/migrations/20251213_add_nexus_core_schema.sql new file mode 100644 index 00000000..348fcd3d --- /dev/null +++ b/supabase/migrations/20251213_add_nexus_core_schema.sql @@ -0,0 +1,355 @@ +-- NEXUS Core: Universal Data Layer +-- Single Source of Truth for talent/contract metadata +-- Supports AZ Tax Commission reporting, time logs, and compliance tracking + +create extension if not exists "pgcrypto"; + +-- ============================================================================ +-- TALENT PROFILES (Legal/Tax Layer) +-- ============================================================================ + +create table if not exists public.nexus_talent_profiles ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null unique references public.user_profiles(id) on delete cascade, + legal_first_name text, + legal_last_name text, + legal_name_encrypted bytea, -- pgcrypto encrypted full legal name + tax_id_encrypted bytea, -- SSN/EIN encrypted + tax_id_last_four text, -- last 4 digits for display + tax_classification text check (tax_classification in ('w2_employee', '1099_contractor', 'corp_entity', 'foreign')), + residency_state text, -- US state code (e.g., 'AZ', 'CA') + residency_country text not null default 'US', + address_line1_encrypted bytea, + address_city text, + address_state text, + address_zip text, + compliance_status text not null default 'pending' check (compliance_status in ('pending', 'verified', 'expired', 'rejected', 'review_needed')), + compliance_verified_at timestamptz, + compliance_expires_at timestamptz, + az_eligible boolean not null default false, -- Eligible for AZ Tax Credit + w9_submitted boolean not null default false, + w9_submitted_at timestamptz, + bank_account_connected boolean not null default false, + stripe_connect_account_id text, + payout_method text default 'stripe' check (payout_method in ('stripe', 'ach', 'check', 'paypal')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists nexus_talent_profiles_user_idx on public.nexus_talent_profiles (user_id); +create index if not exists nexus_talent_profiles_compliance_idx on public.nexus_talent_profiles (compliance_status); +create index if not exists nexus_talent_profiles_az_eligible_idx on public.nexus_talent_profiles (az_eligible); +create index if not exists nexus_talent_profiles_state_idx on public.nexus_talent_profiles (residency_state); + +-- ============================================================================ +-- TIME LOGS (Hour Tracking with AZ Compliance) +-- ============================================================================ + +create table if not exists public.nexus_time_logs ( + id uuid primary key default gen_random_uuid(), + talent_profile_id uuid not null references public.nexus_talent_profiles(id) on delete cascade, + contract_id uuid references public.nexus_contracts(id) on delete set null, + milestone_id uuid references public.nexus_milestones(id) on delete set null, + log_date date not null, + start_time timestamptz not null, + end_time timestamptz not null, + hours_worked numeric(5, 2) not null, + description text, + task_category text, -- 'development', 'design', 'review', 'meeting', etc. + location_type text not null default 'remote' check (location_type in ('remote', 'onsite', 'hybrid')), + location_state text, -- State where work was performed (critical for AZ) + location_city text, + location_latitude numeric(10, 7), + location_longitude numeric(10, 7), + location_verified boolean not null default false, + az_eligible_hours numeric(5, 2) default 0, -- Hours qualifying for AZ Tax Credit + billable boolean not null default true, + billed boolean not null default false, + billed_at timestamptz, + invoice_id uuid references public.corp_invoices(id) on delete set null, + submission_status text not null default 'draft' check (submission_status in ('draft', 'submitted', 'approved', 'rejected', 'exported')), + submitted_at timestamptz, + approved_at timestamptz, + approved_by uuid references public.user_profiles(id) on delete set null, + tax_period text, -- e.g., '2025-Q1', '2025-12' + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists nexus_time_logs_talent_idx on public.nexus_time_logs (talent_profile_id); +create index if not exists nexus_time_logs_contract_idx on public.nexus_time_logs (contract_id); +create index if not exists nexus_time_logs_date_idx on public.nexus_time_logs (log_date desc); +create index if not exists nexus_time_logs_status_idx on public.nexus_time_logs (submission_status); +create index if not exists nexus_time_logs_state_idx on public.nexus_time_logs (location_state); +create index if not exists nexus_time_logs_az_idx on public.nexus_time_logs (az_eligible_hours) where az_eligible_hours > 0; +create index if not exists nexus_time_logs_period_idx on public.nexus_time_logs (tax_period); + +-- ============================================================================ +-- TIME LOG AUDITS (Review & AZ Submission Tracking) +-- ============================================================================ + +create table if not exists public.nexus_time_log_audits ( + id uuid primary key default gen_random_uuid(), + time_log_id uuid not null references public.nexus_time_logs(id) on delete cascade, + reviewer_id uuid references public.user_profiles(id) on delete set null, + audit_type text not null check (audit_type in ('review', 'approval', 'rejection', 'az_submission', 'correction', 'dispute')), + decision text check (decision in ('approved', 'rejected', 'needs_correction', 'submitted', 'acknowledged')), + notes text, + corrections_made jsonb, -- { field: { old: value, new: value } } + az_submission_id text, -- ID from AZ Tax Commission API + az_submission_status text check (az_submission_status in ('pending', 'accepted', 'rejected', 'error')), + az_submission_response jsonb, + ip_address text, + user_agent text, + created_at timestamptz not null default now() +); + +create index if not exists nexus_time_log_audits_log_idx on public.nexus_time_log_audits (time_log_id); +create index if not exists nexus_time_log_audits_reviewer_idx on public.nexus_time_log_audits (reviewer_id); +create index if not exists nexus_time_log_audits_type_idx on public.nexus_time_log_audits (audit_type); +create index if not exists nexus_time_log_audits_az_idx on public.nexus_time_log_audits (az_submission_id) where az_submission_id is not null; + +-- ============================================================================ +-- COMPLIANCE EVENTS (Cross-Entity Audit Trail) +-- ============================================================================ + +create table if not exists public.nexus_compliance_events ( + id uuid primary key default gen_random_uuid(), + entity_type text not null, -- 'talent', 'client', 'contract', 'time_log', 'payout' + entity_id uuid not null, + event_type text not null, -- 'created', 'verified', 'exported', 'access_logged', 'financial_update', etc. + event_category text not null check (event_category in ('compliance', 'financial', 'access', 'data_change', 'tax_reporting', 'legal')), + actor_id uuid references public.user_profiles(id) on delete set null, + actor_role text, -- 'talent', 'client', 'admin', 'system', 'api' + realm_context text, -- 'nexus', 'corp', 'foundation', 'studio' + description text, + payload jsonb, -- Full event data + sensitive_data_accessed boolean not null default false, + financial_amount numeric(12, 2), + legal_entity text, -- 'for_profit', 'non_profit' + cross_entity_access boolean not null default false, -- True if Foundation accessed Corp data + ip_address text, + user_agent text, + created_at timestamptz not null default now() +); + +create index if not exists nexus_compliance_events_entity_idx on public.nexus_compliance_events (entity_type, entity_id); +create index if not exists nexus_compliance_events_type_idx on public.nexus_compliance_events (event_type); +create index if not exists nexus_compliance_events_category_idx on public.nexus_compliance_events (event_category); +create index if not exists nexus_compliance_events_actor_idx on public.nexus_compliance_events (actor_id); +create index if not exists nexus_compliance_events_realm_idx on public.nexus_compliance_events (realm_context); +create index if not exists nexus_compliance_events_cross_entity_idx on public.nexus_compliance_events (cross_entity_access) where cross_entity_access = true; +create index if not exists nexus_compliance_events_created_idx on public.nexus_compliance_events (created_at desc); + +-- ============================================================================ +-- ESCROW LEDGER (Financial Tracking) +-- ============================================================================ + +create table if not exists public.nexus_escrow_ledger ( + id uuid primary key default gen_random_uuid(), + contract_id uuid not null references public.nexus_contracts(id) on delete cascade, + client_id uuid not null references public.user_profiles(id) on delete cascade, + creator_id uuid not null references public.user_profiles(id) on delete cascade, + escrow_balance numeric(12, 2) not null default 0, + funds_deposited numeric(12, 2) not null default 0, + funds_released numeric(12, 2) not null default 0, + funds_refunded numeric(12, 2) not null default 0, + aethex_fees numeric(12, 2) not null default 0, + stripe_customer_id text, + stripe_escrow_intent_id text, + status text not null default 'unfunded' check (status in ('unfunded', 'funded', 'partially_funded', 'released', 'disputed', 'refunded')), + funded_at timestamptz, + released_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists nexus_escrow_ledger_contract_idx on public.nexus_escrow_ledger (contract_id); +create index if not exists nexus_escrow_ledger_client_idx on public.nexus_escrow_ledger (client_id); +create index if not exists nexus_escrow_ledger_creator_idx on public.nexus_escrow_ledger (creator_id); +create index if not exists nexus_escrow_ledger_status_idx on public.nexus_escrow_ledger (status); + +-- ============================================================================ +-- PAYOUT RECORDS (Separate from payments for tax tracking) +-- ============================================================================ + +create table if not exists public.nexus_payouts ( + id uuid primary key default gen_random_uuid(), + talent_profile_id uuid not null references public.nexus_talent_profiles(id) on delete cascade, + contract_id uuid references public.nexus_contracts(id) on delete set null, + payment_id uuid references public.nexus_payments(id) on delete set null, + gross_amount numeric(12, 2) not null, + platform_fee numeric(12, 2) not null default 0, + processing_fee numeric(12, 2) not null default 0, + tax_withholding numeric(12, 2) not null default 0, + net_amount numeric(12, 2) not null, + payout_method text not null default 'stripe' check (payout_method in ('stripe', 'ach', 'check', 'paypal')), + stripe_payout_id text, + ach_trace_number text, + check_number text, + status text not null default 'pending' check (status in ('pending', 'processing', 'completed', 'failed', 'cancelled')), + scheduled_date date, + processed_at timestamptz, + failure_reason text, + tax_year int not null default extract(year from now()), + tax_form_type text, -- '1099-NEC', 'W-2', etc. + tax_form_generated boolean not null default false, + tax_form_file_id text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists nexus_payouts_talent_idx on public.nexus_payouts (talent_profile_id); +create index if not exists nexus_payouts_contract_idx on public.nexus_payouts (contract_id); +create index if not exists nexus_payouts_status_idx on public.nexus_payouts (status); +create index if not exists nexus_payouts_tax_year_idx on public.nexus_payouts (tax_year); +create index if not exists nexus_payouts_scheduled_idx on public.nexus_payouts (scheduled_date); + +-- ============================================================================ +-- FOUNDATION GIG RADAR VIEW (Read-Only Projection) +-- ============================================================================ + +create or replace view public.foundation_gig_radar as +select + o.id as opportunity_id, + o.title, + o.category, + o.required_skills, + o.timeline_type, + o.location_requirement, + o.required_experience, + o.status, + o.published_at, + case + when o.status = 'open' then 'available' + when o.status = 'in_progress' then 'in_progress' + else 'filled' + end as availability_status, + (select count(*) from public.nexus_applications a where a.opportunity_id = o.id) as applicant_count, + case when o.budget_type = 'hourly' then 'hourly' else 'project' end as compensation_type +from public.nexus_opportunities o +where o.status in ('open', 'in_progress') +order by o.published_at desc; + +comment on view public.foundation_gig_radar is 'Read-only view for Foundation Gig Radar - no financial data exposed'; + +-- ============================================================================ +-- RLS POLICIES +-- ============================================================================ + +alter table public.nexus_talent_profiles enable row level security; +alter table public.nexus_time_logs enable row level security; +alter table public.nexus_time_log_audits enable row level security; +alter table public.nexus_compliance_events enable row level security; +alter table public.nexus_escrow_ledger enable row level security; +alter table public.nexus_payouts enable row level security; + +-- Talent Profiles: own profile only (sensitive data) +create policy "Users view own talent profile" on public.nexus_talent_profiles + for select using (auth.uid() = user_id); + +create policy "Users manage own talent profile" on public.nexus_talent_profiles + for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + +create policy "Admins view all talent profiles" on public.nexus_talent_profiles + for select using (exists(select 1 from public.user_profiles where id = auth.uid() and user_type = 'admin')); + +-- Time Logs: talent and contract parties +create policy "Talent views own time logs" on public.nexus_time_logs + for select using ( + auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id) + ); + +create policy "Contract clients view time logs" on public.nexus_time_logs + for select using ( + contract_id is not null and + auth.uid() in (select client_id from public.nexus_contracts where id = contract_id) + ); + +create policy "Talent manages own time logs" on public.nexus_time_logs + for all using ( + auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id) + ) with check ( + auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id) + ); + +-- Time Log Audits: reviewers and talent +create policy "Time log audit visibility" on public.nexus_time_log_audits + for select using ( + auth.uid() = reviewer_id or + auth.uid() in (select tp.user_id from public.nexus_talent_profiles tp join public.nexus_time_logs tl on tp.id = tl.talent_profile_id where tl.id = time_log_id) + ); + +-- Compliance Events: admins only (sensitive audit data) +create policy "Compliance events admin only" on public.nexus_compliance_events + for select using (exists(select 1 from public.user_profiles where id = auth.uid() and user_type = 'admin')); + +create policy "System inserts compliance events" on public.nexus_compliance_events + for insert with check (true); -- Service role only in practice + +-- Escrow Ledger: contract parties +create policy "Escrow visible to contract parties" on public.nexus_escrow_ledger + for select using (auth.uid() = client_id or auth.uid() = creator_id); + +-- Payouts: talent only +create policy "Payouts visible to talent" on public.nexus_payouts + for select using ( + auth.uid() in (select user_id from public.nexus_talent_profiles where id = talent_profile_id) + ); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +create trigger nexus_talent_profiles_set_updated_at before update on public.nexus_talent_profiles for each row execute function public.set_updated_at(); +create trigger nexus_time_logs_set_updated_at before update on public.nexus_time_logs for each row execute function public.set_updated_at(); +create trigger nexus_escrow_ledger_set_updated_at before update on public.nexus_escrow_ledger for each row execute function public.set_updated_at(); +create trigger nexus_payouts_set_updated_at before update on public.nexus_payouts for each row execute function public.set_updated_at(); + +-- ============================================================================ +-- HELPER FUNCTIONS +-- ============================================================================ + +-- Calculate AZ-eligible hours for a time period +create or replace function public.calculate_az_eligible_hours( + p_talent_id uuid, + p_start_date date, + p_end_date date +) returns numeric as $$ + select coalesce(sum(az_eligible_hours), 0) + from public.nexus_time_logs + where talent_profile_id = p_talent_id + and log_date between p_start_date and p_end_date + and location_state = 'AZ' + and submission_status = 'approved'; +$$ language sql stable; + +-- Get talent compliance summary +create or replace function public.get_talent_compliance_summary(p_user_id uuid) +returns jsonb as $$ + select jsonb_build_object( + 'profile_complete', (tp.legal_first_name is not null and tp.tax_id_encrypted is not null), + 'compliance_status', tp.compliance_status, + 'az_eligible', tp.az_eligible, + 'w9_submitted', tp.w9_submitted, + 'bank_connected', tp.bank_account_connected, + 'pending_time_logs', (select count(*) from public.nexus_time_logs where talent_profile_id = tp.id and submission_status = 'submitted'), + 'total_hours_this_month', (select coalesce(sum(hours_worked), 0) from public.nexus_time_logs where talent_profile_id = tp.id and log_date >= date_trunc('month', now())), + 'az_hours_this_month', (select coalesce(sum(az_eligible_hours), 0) from public.nexus_time_logs where talent_profile_id = tp.id and log_date >= date_trunc('month', now()) and location_state = 'AZ') + ) + from public.nexus_talent_profiles tp + where tp.user_id = p_user_id; +$$ language sql stable; + +-- ============================================================================ +-- COMMENTS +-- ============================================================================ + +comment on table public.nexus_talent_profiles is 'Talent legal/tax profiles with encrypted PII for compliance'; +comment on table public.nexus_time_logs is 'Hour tracking with location for AZ Tax Credit eligibility'; +comment on table public.nexus_time_log_audits is 'Audit trail for time log reviews and AZ submissions'; +comment on table public.nexus_compliance_events is 'Cross-entity compliance event log for legal separation'; +comment on table public.nexus_escrow_ledger is 'Escrow account tracking per contract'; +comment on table public.nexus_payouts is 'Payout records with tax form tracking'; +comment on function public.calculate_az_eligible_hours is 'Calculate AZ Tax Credit eligible hours for a talent in a date range'; +comment on function public.get_talent_compliance_summary is 'Get compliance status summary for a talent';