diff --git a/api/admin/analytics.ts b/api/admin/analytics.ts new file mode 100644 index 00000000..d294dd8d --- /dev/null +++ b/api/admin/analytics.ts @@ -0,0 +1,187 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + // Check if user is admin + const { data: profile } = await supabase + .from("profiles") + .select("role") + .eq("id", userData.user.id) + .single(); + + if (!profile || profile.role !== "admin") { + return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } }); + } + + const url = new URL(req.url); + const period = url.searchParams.get("period") || "30"; // days + + try { + if (req.method === "GET") { + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - parseInt(period)); + + // Get total users and growth + const { count: totalUsers } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }); + + const { count: newUsersThisPeriod } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get active users (logged in within period) + const { count: activeUsers } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }) + .gte("last_login_at", daysAgo.toISOString()); + + // Get opportunities stats + const { count: totalOpportunities } = await supabase + .from("aethex_opportunities") + .select("*", { count: "exact", head: true }); + + const { count: openOpportunities } = await supabase + .from("aethex_opportunities") + .select("*", { count: "exact", head: true }) + .eq("status", "open"); + + const { count: newOpportunities } = await supabase + .from("aethex_opportunities") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get applications stats + const { count: totalApplications } = await supabase + .from("aethex_applications") + .select("*", { count: "exact", head: true }); + + const { count: newApplications } = await supabase + .from("aethex_applications") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get contracts stats + const { count: totalContracts } = await supabase + .from("nexus_contracts") + .select("*", { count: "exact", head: true }); + + const { count: activeContracts } = await supabase + .from("nexus_contracts") + .select("*", { count: "exact", head: true }) + .eq("status", "active"); + + // Get community stats + const { count: totalPosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }); + + const { count: newPosts } = await supabase + .from("community_posts") + .select("*", { count: "exact", head: true }) + .gte("created_at", daysAgo.toISOString()); + + // Get creator stats + const { count: totalCreators } = await supabase + .from("aethex_creators") + .select("*", { count: "exact", head: true }); + + // Get daily signups for trend (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const { data: signupTrend } = await supabase + .from("profiles") + .select("created_at") + .gte("created_at", thirtyDaysAgo.toISOString()) + .order("created_at"); + + // Group signups by date + const signupsByDate: Record = {}; + signupTrend?.forEach((user) => { + const date = new Date(user.created_at).toISOString().split("T")[0]; + signupsByDate[date] = (signupsByDate[date] || 0) + 1; + }); + + const dailySignups = Object.entries(signupsByDate).map(([date, count]) => ({ + date, + count + })); + + // Revenue data (if available) + const { data: revenueData } = await supabase + .from("nexus_payments") + .select("amount, created_at") + .eq("status", "completed") + .gte("created_at", daysAgo.toISOString()); + + const totalRevenue = revenueData?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0; + + // Top performing opportunities + const { data: topOpportunities } = await supabase + .from("aethex_opportunities") + .select(` + id, + title, + aethex_applications(count) + `) + .eq("status", "open") + .order("created_at", { ascending: false }) + .limit(5); + + return new Response(JSON.stringify({ + users: { + total: totalUsers || 0, + new: newUsersThisPeriod || 0, + active: activeUsers || 0, + creators: totalCreators || 0 + }, + opportunities: { + total: totalOpportunities || 0, + open: openOpportunities || 0, + new: newOpportunities || 0 + }, + applications: { + total: totalApplications || 0, + new: newApplications || 0 + }, + contracts: { + total: totalContracts || 0, + active: activeContracts || 0 + }, + community: { + posts: totalPosts || 0, + newPosts: newPosts || 0 + }, + revenue: { + total: totalRevenue, + period: `${period} days` + }, + trends: { + dailySignups, + topOpportunities: topOpportunities?.map(o => ({ + id: o.id, + title: o.title, + applications: o.aethex_applications?.[0]?.count || 0 + })) || [] + }, + period: parseInt(period) + }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + console.error("Analytics API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/admin/moderation.ts b/api/admin/moderation.ts new file mode 100644 index 00000000..54877038 --- /dev/null +++ b/api/admin/moderation.ts @@ -0,0 +1,245 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + // Check if user is admin + const { data: profile } = await supabase + .from("profiles") + .select("role") + .eq("id", userData.user.id) + .single(); + + if (!profile || profile.role !== "admin") { + return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } }); + } + + const url = new URL(req.url); + + try { + // GET - Fetch reports and stats + if (req.method === "GET") { + const status = url.searchParams.get("status") || "open"; + const type = url.searchParams.get("type"); // report, dispute, user + + // Get content reports + let reportsQuery = supabase + .from("moderation_reports") + .select(` + *, + reporter:profiles!moderation_reports_reporter_id_fkey(id, full_name, email, avatar_url) + `) + .order("created_at", { ascending: false }) + .limit(50); + + if (status !== "all") { + reportsQuery = reportsQuery.eq("status", status); + } + if (type && type !== "all") { + reportsQuery = reportsQuery.eq("target_type", type); + } + + const { data: reports, error: reportsError } = await reportsQuery; + if (reportsError) console.error("Reports error:", reportsError); + + // Get disputes + let disputesQuery = supabase + .from("nexus_disputes") + .select(` + *, + reporter:profiles!nexus_disputes_reported_by_fkey(id, full_name, email) + `) + .order("created_at", { ascending: false }) + .limit(50); + + if (status !== "all") { + disputesQuery = disputesQuery.eq("status", status); + } + + const { data: disputes, error: disputesError } = await disputesQuery; + if (disputesError) console.error("Disputes error:", disputesError); + + // Get flagged users (users with warnings/bans) + const { data: flaggedUsers } = await supabase + .from("profiles") + .select("id, full_name, email, avatar_url, is_banned, warning_count, created_at") + .or("is_banned.eq.true,warning_count.gt.0") + .order("created_at", { ascending: false }) + .limit(50); + + // Calculate stats + const { count: openReports } = await supabase + .from("moderation_reports") + .select("*", { count: "exact", head: true }) + .eq("status", "open"); + + const { count: openDisputes } = await supabase + .from("nexus_disputes") + .select("*", { count: "exact", head: true }) + .eq("status", "open"); + + const { count: resolvedToday } = await supabase + .from("moderation_reports") + .select("*", { count: "exact", head: true }) + .eq("status", "resolved") + .gte("updated_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString()); + + const stats = { + openReports: openReports || 0, + openDisputes: openDisputes || 0, + resolvedToday: resolvedToday || 0, + flaggedUsers: flaggedUsers?.length || 0 + }; + + return new Response(JSON.stringify({ + reports: reports || [], + disputes: disputes || [], + flaggedUsers: flaggedUsers || [], + stats + }), { headers: { "Content-Type": "application/json" } }); + } + + // POST - Take moderation action + if (req.method === "POST") { + const body = await req.json(); + + // Resolve/ignore report + if (body.action === "update_report") { + const { report_id, status, resolution_notes } = body; + + const { data, error } = await supabase + .from("moderation_reports") + .update({ + status, + resolution_notes, + resolved_by: userData.user.id, + updated_at: new Date().toISOString() + }) + .eq("id", report_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ report: data }), { headers: { "Content-Type": "application/json" } }); + } + + // Resolve dispute + if (body.action === "update_dispute") { + const { dispute_id, status, resolution_notes } = body; + + const { data, error } = await supabase + .from("nexus_disputes") + .update({ + status, + resolution_notes, + resolved_by: userData.user.id, + resolved_at: new Date().toISOString() + }) + .eq("id", dispute_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ dispute: data }), { headers: { "Content-Type": "application/json" } }); + } + + // Ban/warn user + if (body.action === "moderate_user") { + const { user_id, action_type, reason } = body; + + if (action_type === "ban") { + const { data, error } = await supabase + .from("profiles") + .update({ + is_banned: true, + ban_reason: reason, + banned_at: new Date().toISOString(), + banned_by: userData.user.id + }) + .eq("id", user_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ user: data, action: "banned" }), { headers: { "Content-Type": "application/json" } }); + } + + if (action_type === "warn") { + const { data: currentUser } = await supabase + .from("profiles") + .select("warning_count") + .eq("id", user_id) + .single(); + + const { data, error } = await supabase + .from("profiles") + .update({ + warning_count: (currentUser?.warning_count || 0) + 1, + last_warning_at: new Date().toISOString(), + last_warning_reason: reason + }) + .eq("id", user_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ user: data, action: "warned" }), { headers: { "Content-Type": "application/json" } }); + } + + if (action_type === "unban") { + const { data, error } = await supabase + .from("profiles") + .update({ + is_banned: false, + ban_reason: null, + unbanned_at: new Date().toISOString(), + unbanned_by: userData.user.id + }) + .eq("id", user_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ user: data, action: "unbanned" }), { headers: { "Content-Type": "application/json" } }); + } + } + + // Delete content + if (body.action === "delete_content") { + const { content_type, content_id } = body; + + const tableMap: Record = { + post: "community_posts", + comment: "community_comments", + project: "projects", + opportunity: "aethex_opportunities" + }; + + const table = tableMap[content_type]; + if (!table) { + return new Response(JSON.stringify({ error: "Invalid content type" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + const { error } = await supabase.from(table).delete().eq("id", content_id); + if (error) throw error; + + return new Response(JSON.stringify({ success: true, deleted: content_type }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + console.error("Moderation API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/candidate/interviews.ts b/api/candidate/interviews.ts new file mode 100644 index 00000000..21fd16fd --- /dev/null +++ b/api/candidate/interviews.ts @@ -0,0 +1,196 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const userId = userData.user.id; + const url = new URL(req.url); + + try { + // GET - Fetch interviews + if (req.method === "GET") { + const status = url.searchParams.get("status"); + const upcoming = url.searchParams.get("upcoming") === "true"; + + let query = supabase + .from("candidate_interviews") + .select( + ` + *, + employer:profiles!candidate_interviews_employer_id_fkey( + full_name, + avatar_url, + email + ) + `, + ) + .eq("candidate_id", userId) + .order("scheduled_at", { ascending: true }); + + if (status) { + query = query.eq("status", status); + } + + if (upcoming) { + query = query + .gte("scheduled_at", new Date().toISOString()) + .in("status", ["scheduled", "rescheduled"]); + } + + const { data: interviews, error } = await query; + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Group by status + const grouped = { + upcoming: interviews?.filter( + (i) => + ["scheduled", "rescheduled"].includes(i.status) && + new Date(i.scheduled_at) >= new Date(), + ) || [], + past: interviews?.filter( + (i) => + i.status === "completed" || + new Date(i.scheduled_at) < new Date(), + ) || [], + cancelled: interviews?.filter((i) => i.status === "cancelled") || [], + }; + + return new Response( + JSON.stringify({ + interviews: interviews || [], + grouped, + total: interviews?.length || 0, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // POST - Create interview (for self-scheduling or employer invites) + if (req.method === "POST") { + const body = await req.json(); + const { + application_id, + employer_id, + opportunity_id, + scheduled_at, + duration_minutes, + meeting_link, + meeting_type, + notes, + } = body; + + if (!scheduled_at || !employer_id) { + return new Response( + JSON.stringify({ error: "scheduled_at and employer_id are required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const { data, error } = await supabase + .from("candidate_interviews") + .insert({ + application_id, + candidate_id: userId, + employer_id, + opportunity_id, + scheduled_at, + duration_minutes: duration_minutes || 30, + meeting_link, + meeting_type: meeting_type || "video", + notes, + status: "scheduled", + }) + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ interview: data }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + + // PATCH - Update interview (feedback, reschedule) + if (req.method === "PATCH") { + const body = await req.json(); + const { id, candidate_feedback, status, scheduled_at } = body; + + if (!id) { + return new Response(JSON.stringify({ error: "Interview id is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const updateData: Record = {}; + if (candidate_feedback !== undefined) + updateData.candidate_feedback = candidate_feedback; + if (status !== undefined) updateData.status = status; + if (scheduled_at !== undefined) { + updateData.scheduled_at = scheduled_at; + updateData.status = "rescheduled"; + } + + const { data, error } = await supabase + .from("candidate_interviews") + .update(updateData) + .eq("id", id) + .eq("candidate_id", userId) + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ interview: data }), { + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + console.error("Candidate interviews API error:", err); + return new Response(JSON.stringify({ error: err.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; diff --git a/api/candidate/offers.ts b/api/candidate/offers.ts new file mode 100644 index 00000000..3f597af6 --- /dev/null +++ b/api/candidate/offers.ts @@ -0,0 +1,136 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const userId = userData.user.id; + + try { + // GET - Fetch offers + if (req.method === "GET") { + const { data: offers, error } = await supabase + .from("candidate_offers") + .select( + ` + *, + employer:profiles!candidate_offers_employer_id_fkey( + full_name, + avatar_url, + email + ) + `, + ) + .eq("candidate_id", userId) + .order("created_at", { ascending: false }); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Group by status + const grouped = { + pending: offers?.filter((o) => o.status === "pending") || [], + accepted: offers?.filter((o) => o.status === "accepted") || [], + declined: offers?.filter((o) => o.status === "declined") || [], + expired: offers?.filter((o) => o.status === "expired") || [], + withdrawn: offers?.filter((o) => o.status === "withdrawn") || [], + }; + + return new Response( + JSON.stringify({ + offers: offers || [], + grouped, + total: offers?.length || 0, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // PATCH - Respond to offer (accept/decline) + if (req.method === "PATCH") { + const body = await req.json(); + const { id, status, notes } = body; + + if (!id) { + return new Response(JSON.stringify({ error: "Offer id is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!["accepted", "declined"].includes(status)) { + return new Response( + JSON.stringify({ error: "Status must be accepted or declined" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const { data, error } = await supabase + .from("candidate_offers") + .update({ + status, + notes, + candidate_response_at: new Date().toISOString(), + }) + .eq("id", id) + .eq("candidate_id", userId) + .eq("status", "pending") // Can only respond to pending offers + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!data) { + return new Response( + JSON.stringify({ error: "Offer not found or already responded" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify({ offer: data }), { + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + console.error("Candidate offers API error:", err); + return new Response(JSON.stringify({ error: err.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; diff --git a/api/candidate/profile.ts b/api/candidate/profile.ts new file mode 100644 index 00000000..26779f2e --- /dev/null +++ b/api/candidate/profile.ts @@ -0,0 +1,191 @@ +import { supabase } from "../_supabase.js"; + +interface ProfileData { + headline?: string; + bio?: string; + resume_url?: string; + portfolio_urls?: string[]; + work_history?: WorkHistory[]; + education?: Education[]; + skills?: string[]; + availability?: string; + desired_rate?: number; + rate_type?: string; + location?: string; + remote_preference?: string; + is_public?: boolean; +} + +interface WorkHistory { + company: string; + position: string; + start_date: string; + end_date?: string; + current: boolean; + description?: string; +} + +interface Education { + institution: string; + degree: string; + field: string; + start_year: number; + end_year?: number; + current: boolean; +} + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const userId = userData.user.id; + + try { + // GET - Fetch candidate profile + if (req.method === "GET") { + const { data: profile, error } = await supabase + .from("candidate_profiles") + .select("*") + .eq("user_id", userId) + .single(); + + if (error && error.code !== "PGRST116") { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Get user info for basic profile + const { data: userProfile } = await supabase + .from("profiles") + .select("full_name, avatar_url, email") + .eq("id", userId) + .single(); + + // Get application stats + const { data: applications } = await supabase + .from("aethex_applications") + .select("id, status") + .eq("applicant_id", userId); + + const stats = { + total_applications: applications?.length || 0, + pending: applications?.filter((a) => a.status === "pending").length || 0, + reviewed: applications?.filter((a) => a.status === "reviewed").length || 0, + accepted: applications?.filter((a) => a.status === "accepted").length || 0, + rejected: applications?.filter((a) => a.status === "rejected").length || 0, + }; + + return new Response( + JSON.stringify({ + profile: profile || null, + user: userProfile, + stats, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // POST - Create or update profile + if (req.method === "POST") { + const body: ProfileData = await req.json(); + + // Check if profile exists + const { data: existing } = await supabase + .from("candidate_profiles") + .select("id") + .eq("user_id", userId) + .single(); + + if (existing) { + // Update existing profile + const { data, error } = await supabase + .from("candidate_profiles") + .update({ + ...body, + portfolio_urls: body.portfolio_urls + ? JSON.stringify(body.portfolio_urls) + : undefined, + work_history: body.work_history + ? JSON.stringify(body.work_history) + : undefined, + education: body.education + ? JSON.stringify(body.education) + : undefined, + }) + .eq("user_id", userId) + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ profile: data }), { + headers: { "Content-Type": "application/json" }, + }); + } else { + // Create new profile + const { data, error } = await supabase + .from("candidate_profiles") + .insert({ + user_id: userId, + ...body, + portfolio_urls: body.portfolio_urls + ? JSON.stringify(body.portfolio_urls) + : "[]", + work_history: body.work_history + ? JSON.stringify(body.work_history) + : "[]", + education: body.education + ? JSON.stringify(body.education) + : "[]", + }) + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ profile: data }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + console.error("Candidate profile API error:", err); + return new Response(JSON.stringify({ error: err.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; diff --git a/api/staff/announcements.ts b/api/staff/announcements.ts new file mode 100644 index 00000000..346fe1a7 --- /dev/null +++ b/api/staff/announcements.ts @@ -0,0 +1,62 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + + try { + if (req.method === "GET") { + const { data: announcements, error } = await supabase + .from("staff_announcements") + .select(`*, author:profiles!staff_announcements_author_id_fkey(full_name, avatar_url)`) + .or(`expires_at.is.null,expires_at.gt.${new Date().toISOString()}`) + .order("is_pinned", { ascending: false }) + .order("published_at", { ascending: false }); + + if (error) throw error; + + // Mark read status + const withReadStatus = announcements?.map(a => ({ + ...a, + is_read: a.read_by?.includes(userId) || false + })); + + return new Response(JSON.stringify({ announcements: withReadStatus || [] }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + + // Mark as read + if (body.action === "mark_read" && body.id) { + const { data: current } = await supabase + .from("staff_announcements") + .select("read_by") + .eq("id", body.id) + .single(); + + const readBy = current?.read_by || []; + if (!readBy.includes(userId)) { + await supabase + .from("staff_announcements") + .update({ read_by: [...readBy, userId] }) + .eq("id", body.id); + } + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/courses.ts b/api/staff/courses.ts new file mode 100644 index 00000000..688d48a9 --- /dev/null +++ b/api/staff/courses.ts @@ -0,0 +1,100 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + + try { + if (req.method === "GET") { + // Get all courses + const { data: courses, error: coursesError } = await supabase + .from("staff_courses") + .select("*") + .order("title"); + + if (coursesError) throw coursesError; + + // Get user's progress + const { data: progress, error: progressError } = await supabase + .from("staff_course_progress") + .select("*") + .eq("user_id", userId); + + if (progressError) throw progressError; + + // Merge progress with courses + const coursesWithProgress = courses?.map(course => { + const userProgress = progress?.find(p => p.course_id === course.id); + return { + ...course, + progress: userProgress?.progress_percent || 0, + status: userProgress?.status || "available", + started_at: userProgress?.started_at, + completed_at: userProgress?.completed_at + }; + }); + + const stats = { + total: courses?.length || 0, + completed: coursesWithProgress?.filter(c => c.status === "completed").length || 0, + in_progress: coursesWithProgress?.filter(c => c.status === "in_progress").length || 0, + required: courses?.filter(c => c.is_required).length || 0 + }; + + return new Response(JSON.stringify({ courses: coursesWithProgress || [], stats }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + const { course_id, action, progress } = body; + + if (action === "start") { + const { data, error } = await supabase + .from("staff_course_progress") + .upsert({ + user_id: userId, + course_id, + status: "in_progress", + progress_percent: 0, + started_at: new Date().toISOString() + }, { onConflict: "user_id,course_id" }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ progress: data }), { headers: { "Content-Type": "application/json" } }); + } + + if (action === "update_progress") { + const isComplete = progress >= 100; + const { data, error } = await supabase + .from("staff_course_progress") + .upsert({ + user_id: userId, + course_id, + progress_percent: Math.min(progress, 100), + status: isComplete ? "completed" : "in_progress", + completed_at: isComplete ? new Date().toISOString() : null + }, { onConflict: "user_id,course_id" }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ progress: data }), { headers: { "Content-Type": "application/json" } }); + } + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/expenses.ts b/api/staff/expenses.ts new file mode 100644 index 00000000..a70ef1df --- /dev/null +++ b/api/staff/expenses.ts @@ -0,0 +1,96 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + + try { + if (req.method === "GET") { + const { data: expenses, error } = await supabase + .from("staff_expense_reports") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + if (error) throw error; + + const stats = { + total: expenses?.length || 0, + pending: expenses?.filter(e => e.status === "pending").length || 0, + approved: expenses?.filter(e => e.status === "approved").length || 0, + reimbursed: expenses?.filter(e => e.status === "reimbursed").length || 0, + total_amount: expenses?.reduce((sum, e) => sum + parseFloat(e.amount), 0) || 0, + pending_amount: expenses?.filter(e => e.status === "pending").reduce((sum, e) => sum + parseFloat(e.amount), 0) || 0 + }; + + return new Response(JSON.stringify({ expenses: expenses || [], stats }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + const { title, description, amount, category, receipt_url } = body; + + const { data, error } = await supabase + .from("staff_expense_reports") + .insert({ + user_id: userId, + title, + description, + amount, + category, + receipt_url, + status: "pending", + submitted_at: new Date().toISOString() + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ expense: data }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "PATCH") { + const body = await req.json(); + const { id, ...updates } = body; + + const { data, error } = await supabase + .from("staff_expense_reports") + .update(updates) + .eq("id", id) + .eq("user_id", userId) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ expense: data }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "DELETE") { + const url = new URL(req.url); + const id = url.searchParams.get("id"); + + const { error } = await supabase + .from("staff_expense_reports") + .delete() + .eq("id", id) + .eq("user_id", userId) + .in("status", ["draft", "pending"]); + + if (error) throw error; + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/handbook.ts b/api/staff/handbook.ts new file mode 100644 index 00000000..cf48cd8c --- /dev/null +++ b/api/staff/handbook.ts @@ -0,0 +1,46 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + try { + if (req.method === "GET") { + const { data: sections, error } = await supabase + .from("staff_handbook_sections") + .select("*") + .order("category") + .order("order_index"); + + if (error) throw error; + + // Group by category + const grouped = sections?.reduce((acc, section) => { + if (!acc[section.category]) { + acc[section.category] = []; + } + acc[section.category].push(section); + return acc; + }, {} as Record); + + const categories = Object.keys(grouped || {}); + + return new Response(JSON.stringify({ + sections: sections || [], + grouped: grouped || {}, + categories + }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/knowledge-base.ts b/api/staff/knowledge-base.ts new file mode 100644 index 00000000..5fae4fe9 --- /dev/null +++ b/api/staff/knowledge-base.ts @@ -0,0 +1,72 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const url = new URL(req.url); + + try { + if (req.method === "GET") { + const category = url.searchParams.get("category"); + const search = url.searchParams.get("search"); + + let query = supabase + .from("staff_knowledge_articles") + .select(`*, author:profiles!staff_knowledge_articles_author_id_fkey(full_name, avatar_url)`) + .eq("is_published", true) + .order("views", { ascending: false }); + + if (category && category !== "all") { + query = query.eq("category", category); + } + + if (search) { + query = query.or(`title.ilike.%${search}%,content.ilike.%${search}%`); + } + + const { data: articles, error } = await query; + if (error) throw error; + + // Get unique categories + const { data: allArticles } = await supabase + .from("staff_knowledge_articles") + .select("category") + .eq("is_published", true); + + const categories = [...new Set(allArticles?.map(a => a.category) || [])]; + + return new Response(JSON.stringify({ articles: articles || [], categories }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + + // Increment view count + if (body.action === "view" && body.id) { + await supabase.rpc("increment_kb_views", { article_id: body.id }); + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + + // Mark as helpful + if (body.action === "helpful" && body.id) { + await supabase + .from("staff_knowledge_articles") + .update({ helpful_count: supabase.rpc("increment") }) + .eq("id", body.id); + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/marketplace.ts b/api/staff/marketplace.ts new file mode 100644 index 00000000..340a16f3 --- /dev/null +++ b/api/staff/marketplace.ts @@ -0,0 +1,126 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + + try { + if (req.method === "GET") { + // Get items + const { data: items, error: itemsError } = await supabase + .from("staff_marketplace_items") + .select("*") + .eq("is_available", true) + .order("points_cost"); + + if (itemsError) throw itemsError; + + // Get user's points + let { data: points } = await supabase + .from("staff_points") + .select("*") + .eq("user_id", userId) + .single(); + + // Create points record if doesn't exist + if (!points) { + const { data: newPoints } = await supabase + .from("staff_points") + .insert({ user_id: userId, balance: 1000, lifetime_earned: 1000 }) + .select() + .single(); + points = newPoints; + } + + // Get user's orders + const { data: orders } = await supabase + .from("staff_marketplace_orders") + .select(`*, item:staff_marketplace_items(name, image_url)`) + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + return new Response(JSON.stringify({ + items: items || [], + points: points || { balance: 0, lifetime_earned: 0 }, + orders: orders || [] + }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + const { item_id, quantity, shipping_address } = body; + + // Get item + const { data: item } = await supabase + .from("staff_marketplace_items") + .select("*") + .eq("id", item_id) + .single(); + + if (!item) { + return new Response(JSON.stringify({ error: "Item not found" }), { status: 404, headers: { "Content-Type": "application/json" } }); + } + + // Check stock + if (item.stock_count !== null && item.stock_count < (quantity || 1)) { + return new Response(JSON.stringify({ error: "Insufficient stock" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // Check points + const { data: points } = await supabase + .from("staff_points") + .select("balance") + .eq("user_id", userId) + .single(); + + const totalCost = item.points_cost * (quantity || 1); + if (!points || points.balance < totalCost) { + return new Response(JSON.stringify({ error: "Insufficient points" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // Create order + const { data: order, error: orderError } = await supabase + .from("staff_marketplace_orders") + .insert({ + user_id: userId, + item_id, + quantity: quantity || 1, + shipping_address, + status: "pending" + }) + .select() + .single(); + + if (orderError) throw orderError; + + // Deduct points + await supabase + .from("staff_points") + .update({ balance: points.balance - totalCost }) + .eq("user_id", userId); + + // Update stock if applicable + if (item.stock_count !== null) { + await supabase + .from("staff_marketplace_items") + .update({ stock_count: item.stock_count - (quantity || 1) }) + .eq("id", item_id); + } + + return new Response(JSON.stringify({ order }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/okrs.ts b/api/staff/okrs.ts index deee58b0..6335385c 100644 --- a/api/staff/okrs.ts +++ b/api/staff/okrs.ts @@ -1,57 +1,208 @@ import { supabase } from "../_supabase.js"; export default async (req: Request) => { - if (req.method !== "GET") { - return new Response("Method not allowed", { status: 405 }); + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); } + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + const url = new URL(req.url); + try { - const token = req.headers.get("Authorization")?.replace("Bearer ", ""); - if (!token) { - return new Response("Unauthorized", { status: 401 }); - } + // GET - Fetch OKRs with key results + if (req.method === "GET") { + const quarter = url.searchParams.get("quarter"); + const year = url.searchParams.get("year"); + const status = url.searchParams.get("status"); - const { data: userData } = await supabase.auth.getUser(token); - if (!userData.user) { - return new Response("Unauthorized", { status: 401 }); - } + let query = supabase + .from("staff_okrs") + .select(` + *, + key_results:staff_key_results(*) + `) + .or(`user_id.eq.${userId},owner_type.in.(team,company)`) + .order("created_at", { ascending: false }); - const { data: okrs, error } = await supabase - .from("staff_okrs") - .select( - ` - id, - user_id, - objective, - description, - status, - quarter, - year, - key_results( - id, - title, - progress, - target_value - ), - created_at - `, - ) - .eq("user_id", userData.user.id) - .order("created_at", { ascending: false }); + if (quarter) query = query.eq("quarter", parseInt(quarter)); + if (year) query = query.eq("year", parseInt(year)); + if (status) query = query.eq("status", status); - if (error) { - console.error("OKRs fetch error:", error); - return new Response(JSON.stringify({ error: error.message }), { - status: 500, + const { data: okrs, error } = await query; + if (error) throw error; + + // Calculate stats + const myOkrs = okrs?.filter(o => o.user_id === userId) || []; + const stats = { + total: myOkrs.length, + active: myOkrs.filter(o => o.status === "active").length, + completed: myOkrs.filter(o => o.status === "completed").length, + avgProgress: myOkrs.length > 0 + ? Math.round(myOkrs.reduce((sum, o) => sum + (o.progress || 0), 0) / myOkrs.length) + : 0 + }; + + return new Response(JSON.stringify({ okrs: okrs || [], stats }), { + headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify(okrs || []), { - headers: { "Content-Type": "application/json" }, - }); + // POST - Create OKR or Key Result + if (req.method === "POST") { + const body = await req.json(); + + // Create new OKR + if (body.action === "create_okr") { + const { objective, description, quarter, year, team, owner_type } = body; + + const { data: okr, error } = await supabase + .from("staff_okrs") + .insert({ + user_id: userId, + objective, + description, + quarter, + year, + team, + owner_type: owner_type || "individual", + status: "draft" + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ okr }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Add key result to OKR + if (body.action === "add_key_result") { + const { okr_id, title, description, target_value, metric_type, unit, due_date } = body; + + const { data: keyResult, error } = await supabase + .from("staff_key_results") + .insert({ + okr_id, + title, + description, + target_value, + metric_type: metric_type || "percentage", + unit, + due_date + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ key_result: keyResult }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Update key result progress + if (body.action === "update_key_result") { + const { key_result_id, current_value, status } = body; + + // Get target value to calculate progress + const { data: kr } = await supabase + .from("staff_key_results") + .select("target_value, start_value") + .eq("id", key_result_id) + .single(); + + const progress = kr ? Math.min(100, Math.round(((current_value - (kr.start_value || 0)) / (kr.target_value - (kr.start_value || 0))) * 100)) : 0; + + const { data: keyResult, error } = await supabase + .from("staff_key_results") + .update({ + current_value, + progress: Math.max(0, progress), + status: status || (progress >= 100 ? "completed" : progress >= 70 ? "on_track" : progress >= 40 ? "at_risk" : "behind"), + updated_at: new Date().toISOString() + }) + .eq("id", key_result_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ key_result: keyResult }), { headers: { "Content-Type": "application/json" } }); + } + + // Add check-in + if (body.action === "add_checkin") { + const { okr_id, notes, progress_snapshot } = body; + + const { data: checkin, error } = await supabase + .from("staff_okr_checkins") + .insert({ + okr_id, + user_id: userId, + notes, + progress_snapshot + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ checkin }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // PUT - Update OKR + if (req.method === "PUT") { + const body = await req.json(); + const { id, objective, description, status, quarter, year } = body; + + const { data: okr, error } = await supabase + .from("staff_okrs") + .update({ + objective, + description, + status, + quarter, + year, + updated_at: new Date().toISOString() + }) + .eq("id", id) + .eq("user_id", userId) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ okr }), { headers: { "Content-Type": "application/json" } }); + } + + // DELETE - Delete OKR or Key Result + if (req.method === "DELETE") { + const id = url.searchParams.get("id"); + const type = url.searchParams.get("type") || "okr"; + + if (type === "key_result") { + const { error } = await supabase + .from("staff_key_results") + .delete() + .eq("id", id); + if (error) throw error; + } else { + const { error } = await supabase + .from("staff_okrs") + .delete() + .eq("id", id) + .eq("user_id", userId); + if (error) throw error; + } + + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); } catch (err: any) { - return new Response(JSON.stringify({ error: err.message }), { - status: 500, - }); + console.error("OKR API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); } }; diff --git a/api/staff/onboarding.ts b/api/staff/onboarding.ts new file mode 100644 index 00000000..8aac6a8f --- /dev/null +++ b/api/staff/onboarding.ts @@ -0,0 +1,289 @@ +import { supabase } from "../_supabase.js"; + +interface ChecklistItem { + id: string; + checklist_item: string; + phase: string; + completed: boolean; + completed_at: string | null; + notes: string | null; +} + +interface OnboardingMetadata { + start_date: string; + manager_id: string | null; + department: string | null; + role_title: string | null; + onboarding_completed: boolean; +} + +// Default checklist items for new staff +const DEFAULT_CHECKLIST_ITEMS = [ + // Day 1 + { item: "Complete HR paperwork", phase: "day1" }, + { item: "Set up workstation", phase: "day1" }, + { item: "Join Discord server", phase: "day1" }, + { item: "Meet your manager", phase: "day1" }, + { item: "Review company handbook", phase: "day1" }, + { item: "Set up email and accounts", phase: "day1" }, + // Week 1 + { item: "Complete security training", phase: "week1" }, + { item: "Set up development environment", phase: "week1" }, + { item: "Review codebase architecture", phase: "week1" }, + { item: "Attend team standup", phase: "week1" }, + { item: "Complete first small task", phase: "week1" }, + { item: "Meet team members", phase: "week1" }, + // Month 1 + { item: "Complete onboarding course", phase: "month1" }, + { item: "Contribute to first sprint", phase: "month1" }, + { item: "30-day check-in with manager", phase: "month1" }, + { item: "Set Q1 OKRs", phase: "month1" }, + { item: "Shadow a senior team member", phase: "month1" }, +]; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const userId = userData.user.id; + const url = new URL(req.url); + + try { + // GET - Fetch onboarding progress + if (req.method === "GET") { + // Check for admin view (managers viewing team progress) + if (url.pathname.endsWith("/admin")) { + // Get team members for this manager + const { data: teamMembers, error: teamError } = await supabase + .from("staff_members") + .select("user_id, full_name, email, avatar_url, start_date") + .eq("manager_id", userId); + + if (teamError) { + return new Response(JSON.stringify({ error: teamError.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!teamMembers || teamMembers.length === 0) { + return new Response(JSON.stringify({ team: [] }), { + headers: { "Content-Type": "application/json" }, + }); + } + + // Get progress for all team members + const userIds = teamMembers.map((m) => m.user_id); + const { data: progressData } = await supabase + .from("staff_onboarding_progress") + .select("*") + .in("user_id", userIds); + + // Calculate completion for each team member + const teamProgress = teamMembers.map((member) => { + const memberProgress = progressData?.filter( + (p) => p.user_id === member.user_id, + ); + const completed = + memberProgress?.filter((p) => p.completed).length || 0; + const total = DEFAULT_CHECKLIST_ITEMS.length; + return { + ...member, + progress_completed: completed, + progress_total: total, + progress_percentage: Math.round((completed / total) * 100), + }; + }); + + return new Response(JSON.stringify({ team: teamProgress }), { + headers: { "Content-Type": "application/json" }, + }); + } + + // Regular user view - get own progress + const { data: progress, error: progressError } = await supabase + .from("staff_onboarding_progress") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: true }); + + // Get or create metadata + let { data: metadata, error: metadataError } = await supabase + .from("staff_onboarding_metadata") + .select("*") + .eq("user_id", userId) + .single(); + + // If no metadata exists, create it + if (!metadata && metadataError?.code === "PGRST116") { + const { data: newMetadata } = await supabase + .from("staff_onboarding_metadata") + .insert({ user_id: userId }) + .select() + .single(); + metadata = newMetadata; + } + + // Get staff member info for name/department + const { data: staffMember } = await supabase + .from("staff_members") + .select("full_name, department, role, avatar_url") + .eq("user_id", userId) + .single(); + + // Get manager info if exists + let managerInfo = null; + if (metadata?.manager_id) { + const { data: manager } = await supabase + .from("staff_members") + .select("full_name, email, avatar_url") + .eq("user_id", metadata.manager_id) + .single(); + managerInfo = manager; + } + + // If no progress exists, initialize with default items + let progressItems = progress || []; + if (!progress || progress.length === 0) { + const itemsToInsert = DEFAULT_CHECKLIST_ITEMS.map((item) => ({ + user_id: userId, + checklist_item: item.item, + phase: item.phase, + completed: false, + })); + + const { data: insertedItems } = await supabase + .from("staff_onboarding_progress") + .insert(itemsToInsert) + .select(); + + progressItems = insertedItems || []; + } + + // Group by phase + const groupedProgress = { + day1: progressItems.filter((p) => p.phase === "day1"), + week1: progressItems.filter((p) => p.phase === "week1"), + month1: progressItems.filter((p) => p.phase === "month1"), + }; + + // Calculate overall progress + const completed = progressItems.filter((p) => p.completed).length; + const total = progressItems.length; + + return new Response( + JSON.stringify({ + progress: groupedProgress, + metadata: metadata || { start_date: new Date().toISOString() }, + staff_member: staffMember, + manager: managerInfo, + summary: { + completed, + total, + percentage: total > 0 ? Math.round((completed / total) * 100) : 0, + }, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // POST - Mark item complete/incomplete + if (req.method === "POST") { + const body = await req.json(); + const { checklist_item, completed, notes } = body; + + if (!checklist_item) { + return new Response( + JSON.stringify({ error: "checklist_item is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + // Upsert the progress item + const { data, error } = await supabase + .from("staff_onboarding_progress") + .upsert( + { + user_id: userId, + checklist_item, + phase: + DEFAULT_CHECKLIST_ITEMS.find((i) => i.item === checklist_item) + ?.phase || "day1", + completed: completed ?? true, + completed_at: completed ? new Date().toISOString() : null, + notes: notes || null, + }, + { + onConflict: "user_id,checklist_item", + }, + ) + .select() + .single(); + + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + // Check if all items are complete + const { data: allProgress } = await supabase + .from("staff_onboarding_progress") + .select("completed") + .eq("user_id", userId); + + const allCompleted = allProgress?.every((p) => p.completed); + + // Update metadata if all completed + if (allCompleted) { + await supabase + .from("staff_onboarding_metadata") + .update({ + onboarding_completed: true, + onboarding_completed_at: new Date().toISOString(), + }) + .eq("user_id", userId); + } + + return new Response( + JSON.stringify({ + item: data, + all_completed: allCompleted, + }), + { + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: any) { + console.error("Onboarding API error:", err); + return new Response(JSON.stringify({ error: err.message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; diff --git a/api/staff/projects.ts b/api/staff/projects.ts new file mode 100644 index 00000000..8a71b67f --- /dev/null +++ b/api/staff/projects.ts @@ -0,0 +1,102 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + + try { + if (req.method === "GET") { + // Get projects where user is lead or team member + const { data: projects, error } = await supabase + .from("staff_projects") + .select(` + *, + lead:profiles!staff_projects_lead_id_fkey(full_name, avatar_url) + `) + .or(`lead_id.eq.${userId},team_members.cs.{${userId}}`) + .order("updated_at", { ascending: false }); + + if (error) throw error; + + // Get tasks for each project + const projectIds = projects?.map(p => p.id) || []; + const { data: tasks } = await supabase + .from("staff_project_tasks") + .select("*") + .in("project_id", projectIds); + + // Attach tasks to projects + const projectsWithTasks = projects?.map(project => ({ + ...project, + tasks: tasks?.filter(t => t.project_id === project.id) || [], + task_stats: { + total: tasks?.filter(t => t.project_id === project.id).length || 0, + done: tasks?.filter(t => t.project_id === project.id && t.status === "done").length || 0 + } + })); + + const stats = { + total: projects?.length || 0, + active: projects?.filter(p => p.status === "active").length || 0, + completed: projects?.filter(p => p.status === "completed").length || 0 + }; + + return new Response(JSON.stringify({ projects: projectsWithTasks || [], stats }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + + // Update task status + if (body.action === "update_task") { + const { task_id, status } = body; + const { data, error } = await supabase + .from("staff_project_tasks") + .update({ + status, + completed_at: status === "done" ? new Date().toISOString() : null + }) + .eq("id", task_id) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ task: data }), { headers: { "Content-Type": "application/json" } }); + } + + // Create task + if (body.action === "create_task") { + const { project_id, title, description, due_date, priority } = body; + const { data, error } = await supabase + .from("staff_project_tasks") + .insert({ + project_id, + title, + description, + due_date, + priority, + assignee_id: userId, + status: "todo" + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ task: data }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/reviews.ts b/api/staff/reviews.ts new file mode 100644 index 00000000..dc6b85c7 --- /dev/null +++ b/api/staff/reviews.ts @@ -0,0 +1,60 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + + try { + if (req.method === "GET") { + const { data: reviews, error } = await supabase + .from("staff_performance_reviews") + .select(` + *, + reviewer:profiles!staff_performance_reviews_reviewer_id_fkey(full_name, avatar_url) + `) + .eq("employee_id", userId) + .order("created_at", { ascending: false }); + + if (error) throw error; + + const stats = { + total: reviews?.length || 0, + pending: reviews?.filter(r => r.status === "pending").length || 0, + completed: reviews?.filter(r => r.status === "completed").length || 0, + average_rating: reviews?.filter(r => r.overall_rating).reduce((sum, r) => sum + r.overall_rating, 0) / (reviews?.filter(r => r.overall_rating).length || 1) || 0 + }; + + return new Response(JSON.stringify({ reviews: reviews || [], stats }), { headers: { "Content-Type": "application/json" } }); + } + + if (req.method === "POST") { + const body = await req.json(); + const { review_id, employee_comments } = body; + + // Employee can only add their comments + const { data, error } = await supabase + .from("staff_performance_reviews") + .update({ employee_comments }) + .eq("id", review_id) + .eq("employee_id", userId) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ review: data }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/api/staff/time-tracking.ts b/api/staff/time-tracking.ts new file mode 100644 index 00000000..73f5b609 --- /dev/null +++ b/api/staff/time-tracking.ts @@ -0,0 +1,245 @@ +import { supabase } from "../_supabase.js"; + +export default async (req: Request) => { + const token = req.headers.get("Authorization")?.replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const { data: userData } = await supabase.auth.getUser(token); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }); + } + + const userId = userData.user.id; + const url = new URL(req.url); + + try { + // GET - Fetch time entries and timesheets + if (req.method === "GET") { + const startDate = url.searchParams.get("start_date"); + const endDate = url.searchParams.get("end_date"); + const view = url.searchParams.get("view") || "week"; // week, month, all + + // Calculate default date range based on view + const now = new Date(); + let defaultStart: string; + let defaultEnd: string; + + if (view === "week") { + const dayOfWeek = now.getDay(); + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - dayOfWeek); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + defaultStart = weekStart.toISOString().split("T")[0]; + defaultEnd = weekEnd.toISOString().split("T")[0]; + } else if (view === "month") { + defaultStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split("T")[0]; + defaultEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split("T")[0]; + } else { + defaultStart = new Date(now.getFullYear(), 0, 1).toISOString().split("T")[0]; + defaultEnd = new Date(now.getFullYear(), 11, 31).toISOString().split("T")[0]; + } + + const rangeStart = startDate || defaultStart; + const rangeEnd = endDate || defaultEnd; + + // Get time entries + const { data: entries, error: entriesError } = await supabase + .from("staff_time_entries") + .select(` + *, + project:staff_projects(id, name), + task:staff_project_tasks(id, title) + `) + .eq("user_id", userId) + .gte("date", rangeStart) + .lte("date", rangeEnd) + .order("date", { ascending: false }) + .order("start_time", { ascending: false }); + + if (entriesError) throw entriesError; + + // Get projects for dropdown + const { data: projects } = await supabase + .from("staff_projects") + .select("id, name") + .or(`lead_id.eq.${userId},team_members.cs.{${userId}}`) + .eq("status", "active"); + + // Calculate stats + const totalMinutes = entries?.reduce((sum, e) => sum + (e.duration_minutes || 0), 0) || 0; + const billableMinutes = entries?.filter(e => e.is_billable).reduce((sum, e) => sum + (e.duration_minutes || 0), 0) || 0; + + const stats = { + totalHours: Math.round((totalMinutes / 60) * 10) / 10, + billableHours: Math.round((billableMinutes / 60) * 10) / 10, + entriesCount: entries?.length || 0, + avgHoursPerDay: entries?.length ? Math.round((totalMinutes / 60 / new Set(entries.map(e => e.date)).size) * 10) / 10 : 0 + }; + + return new Response(JSON.stringify({ + entries: entries || [], + projects: projects || [], + stats, + dateRange: { start: rangeStart, end: rangeEnd } + }), { headers: { "Content-Type": "application/json" } }); + } + + // POST - Create time entry or actions + if (req.method === "POST") { + const body = await req.json(); + + // Create time entry + if (body.action === "create_entry") { + const { project_id, task_id, description, date, start_time, end_time, duration_minutes, is_billable, notes } = body; + + // Calculate duration if start/end provided + let calculatedDuration = duration_minutes; + if (start_time && end_time && !duration_minutes) { + const [sh, sm] = start_time.split(":").map(Number); + const [eh, em] = end_time.split(":").map(Number); + calculatedDuration = (eh * 60 + em) - (sh * 60 + sm); + } + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .insert({ + user_id: userId, + project_id, + task_id, + description, + date: date || new Date().toISOString().split("T")[0], + start_time, + end_time, + duration_minutes: calculatedDuration || 0, + is_billable: is_billable !== false, + notes + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Start timer (quick entry) + if (body.action === "start_timer") { + const { project_id, description } = body; + const now = new Date(); + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .insert({ + user_id: userId, + project_id, + description: description || "Time tracking", + date: now.toISOString().split("T")[0], + start_time: now.toTimeString().split(" ")[0].substring(0, 5), + duration_minutes: 0, + is_billable: true + }) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { status: 201, headers: { "Content-Type": "application/json" } }); + } + + // Stop timer + if (body.action === "stop_timer") { + const { entry_id } = body; + const now = new Date(); + const endTime = now.toTimeString().split(" ")[0].substring(0, 5); + + // Get the entry to calculate duration + const { data: existing } = await supabase + .from("staff_time_entries") + .select("start_time") + .eq("id", entry_id) + .single(); + + if (existing?.start_time) { + const [sh, sm] = existing.start_time.split(":").map(Number); + const [eh, em] = endTime.split(":").map(Number); + const duration = (eh * 60 + em) - (sh * 60 + sm); + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .update({ + end_time: endTime, + duration_minutes: Math.max(0, duration), + updated_at: new Date().toISOString() + }) + .eq("id", entry_id) + .eq("user_id", userId) + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { headers: { "Content-Type": "application/json" } }); + } + } + + return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // PUT - Update time entry + if (req.method === "PUT") { + const body = await req.json(); + const { id, project_id, task_id, description, date, start_time, end_time, duration_minutes, is_billable, notes } = body; + + // Calculate duration if times provided + let calculatedDuration = duration_minutes; + if (start_time && end_time) { + const [sh, sm] = start_time.split(":").map(Number); + const [eh, em] = end_time.split(":").map(Number); + calculatedDuration = (eh * 60 + em) - (sh * 60 + sm); + } + + const { data: entry, error } = await supabase + .from("staff_time_entries") + .update({ + project_id, + task_id, + description, + date, + start_time, + end_time, + duration_minutes: calculatedDuration, + is_billable, + notes, + updated_at: new Date().toISOString() + }) + .eq("id", id) + .eq("user_id", userId) + .eq("status", "draft") + .select() + .single(); + + if (error) throw error; + return new Response(JSON.stringify({ entry }), { headers: { "Content-Type": "application/json" } }); + } + + // DELETE - Delete time entry + if (req.method === "DELETE") { + const id = url.searchParams.get("id"); + + const { error } = await supabase + .from("staff_time_entries") + .delete() + .eq("id", id) + .eq("user_id", userId) + .eq("status", "draft"); + + if (error) throw error; + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } }); + } catch (err: any) { + console.error("Time tracking API error:", err); + return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } +}; diff --git a/client/App.tsx b/client/App.tsx index 1547582c..e77ad1d1 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -260,6 +260,22 @@ const App = () => ( } /> } /> } /> + + + + } + /> + + + + } + /> } /> } /> } /> @@ -430,6 +446,24 @@ const App = () => ( } /> + {/* Staff Onboarding Routes */} + + + + } + /> + + + + } + /> + {/* Staff Management Routes */} ( } /> + + + + } + /> + + + + } + /> + + {/* Candidate Portal Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Dev-Link routes - now redirect to Nexus Opportunities with ecosystem filter */} } /> diff --git a/client/components/ProfileEditor.tsx b/client/components/ProfileEditor.tsx index 2878f304..9a4f6529 100644 --- a/client/components/ProfileEditor.tsx +++ b/client/components/ProfileEditor.tsx @@ -196,7 +196,7 @@ export function ProfileEditor({ return ( - + Basic Social Skills diff --git a/client/components/admin/AdminStaffAdmin.tsx b/client/components/admin/AdminStaffAdmin.tsx index 1660b37a..65ffd700 100644 --- a/client/components/admin/AdminStaffAdmin.tsx +++ b/client/components/admin/AdminStaffAdmin.tsx @@ -80,7 +80,7 @@ export default function AdminStaffAdmin() { - + Users diff --git a/client/components/ai/AIChat.tsx b/client/components/ai/AIChat.tsx index b80f9c10..325ad0e1 100644 --- a/client/components/ai/AIChat.tsx +++ b/client/components/ai/AIChat.tsx @@ -209,7 +209,7 @@ export const AIChat: React.FC = ({ animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 20, scale: 0.95 }} transition={{ type: 'spring', damping: 25, stiffness: 300 }} - className="fixed bottom-4 right-4 md:bottom-6 md:right-6 w-[calc(100vw-2rem)] md:w-[450px] h-[600px] max-h-[80vh] bg-background border border-border rounded-2xl shadow-2xl z-50 flex flex-col overflow-hidden" + className="fixed bottom-4 right-4 md:bottom-6 md:right-6 w-[calc(100vw-2rem)] md:w-[450px] h-[70vh] sm:h-[600px] max-h-[80vh] bg-background border border-border rounded-2xl shadow-2xl z-50 flex flex-col overflow-hidden" >
- + diff --git a/client/pages/BotPanel.tsx b/client/pages/BotPanel.tsx index 4dad8d4f..7c4cff44 100644 --- a/client/pages/BotPanel.tsx +++ b/client/pages/BotPanel.tsx @@ -347,7 +347,7 @@ export default function BotPanel() {
)} -
+

Commands

@@ -379,7 +379,7 @@ export default function BotPanel() { -

+

{feedStats?.totalPosts || 0}

Total Posts

diff --git a/client/pages/Dashboard.tsx b/client/pages/Dashboard.tsx index 3d783a33..b1233b5c 100644 --- a/client/pages/Dashboard.tsx +++ b/client/pages/Dashboard.tsx @@ -393,7 +393,7 @@ export default function Dashboard() { onValueChange={setActiveTab} className="w-full" > - + Realms Arms diff --git a/client/pages/Foundation.tsx b/client/pages/Foundation.tsx index b915320c..620f237f 100644 --- a/client/pages/Foundation.tsx +++ b/client/pages/Foundation.tsx @@ -1,31 +1,24 @@ import Layout from "@/components/Layout"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useArmTheme } from "@/contexts/ArmThemeContext"; +import { Card, CardContent } from "@/components/ui/card"; import { Heart, - BookOpen, - Code, - Users, - Zap, + ExternalLink, ArrowRight, - GraduationCap, Gamepad2, + Users, + Code, + GraduationCap, Sparkles, Trophy, Compass, ExternalLink, } from "lucide-react"; -import { useNavigate } from "react-router-dom"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import LoadingScreen from "@/components/LoadingScreen"; -import { useArmToast } from "@/hooks/use-arm-toast"; export default function Foundation() { - const navigate = useNavigate(); - const { theme } = useArmTheme(); - const armToast = useArmToast(); const [isLoading, setIsLoading] = useState(true); const [showTldr, setShowTldr] = useState(false); const [showExitModal, setShowExitModal] = useState(false); @@ -34,14 +27,31 @@ export default function Foundation() { useEffect(() => { const timer = setTimeout(() => { setIsLoading(false); - if (!toastShownRef.current) { - armToast.system("Foundation network connected"); - toastShownRef.current = true; - } }, 900); return () => clearTimeout(timer); - }, [armToast]); + }, []); + + // Countdown timer for auto-redirect + useEffect(() => { + if (isLoading) return; + + const interval = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + window.location.href = "https://aethex.foundation"; + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [isLoading]); + + const handleRedirect = () => { + window.location.href = "https://aethex.foundation"; + }; // Exit intent detection useEffect(() => { @@ -178,311 +188,135 @@ export default function Foundation() { 30-day mentorship sprints where developers ship real games

+ + Non-Profit Guardian + +

+ AeThex Foundation +

+

+ The heart of our ecosystem. Dedicated to community, mentorship, + and advancing game development through open-source innovation. +

- - - {/* What is GameForge? */} + + {/* Redirect Notice */} +
+
+ + Foundation Has Moved +
+

+ The AeThex Foundation now has its own dedicated home. Visit our + new site for programs, resources, and community updates. +

+ +

+ Redirecting automatically in {countdown} seconds... +

+
+ + {/* Quick Links */}
-

- - What is GameForge? -

-

- GameForge is the Foundation's flagship "master-apprentice" - mentorship program. It's our "gym" where developers - collaborate on focused, high-impact game projects within - 30-day sprints. Teams of 5 (1 mentor + 4 mentees) tackle real - game development challenges and ship playable games to our - community arcade. -

-
- - {/* The Triple Win */} -
-

- - Why GameForge Matters -

-
-
-

- Role 1: Community -

-

- Our "campfire" where developers meet, collaborate, and - build their `aethex.me` passports through real project - work. -

-
-
-

- Role 2: Education -

-

- Learn professional development practices: Code Review - (SOP-102), Scope Management (KND-001), and shipping - excellence. -

-
-
-

- Role 3: Pipeline -

-

- Top performers become "Architects" ready to work on - high-value projects. Your GameForge portfolio proves you - can execute. -

-
-
-
- - {/* How It Works */} -
-

- - How It Works -

-
-
- - 1. - -
-

- Join a 5-Person Team -

-

- 1 Forge Master (Mentor) + 4 Apprentices (Scripter, - Builder, Sound, Narrative) -

-
-
-
- - 2. - -
-

- Ship in 30 Days -

-

- Focused sprint with a strict 1-paragraph GDD. No scope - creep. Execute with excellence. -

-
-
-
- - 3. - -
-

- Ship to the Arcade -

-

- Your finished game goes live on aethex.fun. Add it to - your Passport portfolio. -

-
-
-
- - 4. - -
-

- Level Up Your Career -

-

- 3 shipped games = Architect status. Qualify for premium - opportunities on NEXUS. -

-
-
-
-
- - {/* CTA Button */} - -
- - - {/* Foundation Mission & Values */} -
-

- - Our Mission -

- - -

- The AeThex Foundation is a non-profit organization dedicated - to advancing game development through community-driven - mentorship, open-source innovation, and educational - excellence. We believe that great developers are built, not - born—and that the future of gaming lies in collaboration, - transparency, and shared knowledge. -

-
-
-

- - Community is Our Core -

-

- Building lasting relationships and support networks within - game development. -

-
-
-

- - Open Innovation -

-

- Advancing the industry through open-source Axiom Protocol - and shared tools. -

-
-
-

- - Excellence & Growth -

-

- Mentoring developers to ship real products and achieve - their potential. -

-
-
-
-
-
- - {/* Other Programs */} -
-

- - Foundation Programs -

-
- {/* Mentorship Program */} - - - Mentorship Network - - -

- Learn from industry veterans. Our mentors bring real-world - experience from studios, indie teams, and AAA development. -

- -
-
- - {/* Open Source */} - - - Axiom Protocol - - -

- Our open-source protocol for game development. Contribute, - learn, and help shape the future of the industry. -

- -
-
- - {/* Courses */} - - - Learning Paths - - -

- Structured curricula covering game design, programming, art, - sound, and narrative design from basics to advanced. -

- -
-
- - {/* Community */} - - - Community Hub - - -

- Connect with developers, share projects, get feedback, and - build lasting professional relationships. -

- -
-
-
-
- - {/* Call to Action */} - - -
-

- Ready to Join the Foundation? +

+ Foundation Highlights

-

- Whether you're looking to learn, mentor others, or contribute - to open-source game development, there's a place for you here. -

+
-
- - + + {/* Footer Note */} +
+

+ The AeThex Foundation is a 501(c)(3) non-profit organization + dedicated to advancing game development education and community. +

diff --git a/client/pages/Maintenance.tsx b/client/pages/Maintenance.tsx index db4a24a0..d9b9503f 100644 --- a/client/pages/Maintenance.tsx +++ b/client/pages/Maintenance.tsx @@ -108,7 +108,7 @@ export default function MaintenancePage() {
-
+
STATUS
diff --git a/client/pages/ProjectsAdmin.tsx b/client/pages/ProjectsAdmin.tsx index bd0ddc00..dbb5c4ac 100644 --- a/client/pages/ProjectsAdmin.tsx +++ b/client/pages/ProjectsAdmin.tsx @@ -158,7 +158,7 @@ export default function ProjectsAdmin() { value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} /> -
+
+ + + + + Last 7 days + Last 30 days + Last 90 days + Last year + + +
+ + {/* Overview Stats */} +
+ + +
+
+

Total Users

+

{analytics?.users.total.toLocaleString()}

+
+ + +{analytics?.users.new} this period +
+
+ +
+
+
+ + +
+
+

Active Users

+

{analytics?.users.active.toLocaleString()}

+

+ {analytics?.users.total ? Math.round((analytics.users.active / analytics.users.total) * 100) : 0}% of total +

+
+ +
+
+
+ + +
+
+

Opportunities

+

{analytics?.opportunities.open}

+
+ + +{analytics?.opportunities.new} new +
+
+ +
+
+
+ + +
+
+

Revenue

+

{formatCurrency(analytics?.revenue.total || 0)}

+

Last {period} days

+
+ +
+
+
+
+ + {/* Detailed Stats Grid */} +
+ {/* Applications */} + + + + + Applications + + + +
+
+ Total + {analytics?.applications.total} +
+
+ This Period + +{analytics?.applications.new} +
+
+ Avg per Opportunity + + {analytics?.opportunities.total + ? (analytics.applications.total / analytics.opportunities.total).toFixed(1) + : 0} + +
+
+
+
+ + {/* Contracts */} + + + + + Contracts + + + +
+
+ Total + {analytics?.contracts.total} +
+
+ Active + {analytics?.contracts.active} +
+
+ Completion Rate + + {analytics?.contracts.total + ? Math.round(((analytics.contracts.total - analytics.contracts.active) / analytics.contracts.total) * 100) + : 0}% + +
+
+
+
+ + {/* Community */} + + + + + Community + + + +
+
+ Total Posts + {analytics?.community.posts} +
+
+ New Posts + +{analytics?.community.newPosts} +
+
+ Creators + {analytics?.users.creators} +
+
+
+
+
+ + {/* Charts Row */} +
+ {/* Signup Trend */} + + + Daily Signups + User registrations over the last 30 days + + +
+ {analytics?.trends.dailySignups.slice(-30).map((day, i) => ( +
+ ))} +
+
+ 30 days ago + Today +
+ + + + {/* Top Opportunities */} + + + Top Opportunities + By number of applications + + +
+ {analytics?.trends.topOpportunities.map((opp, i) => ( +
+ #{i + 1} +
+

{opp.title}

+

{opp.applications} applications

+
+
+ ))} + {(!analytics?.trends.topOpportunities || analytics.trends.topOpportunities.length === 0) && ( +

No opportunities yet

+ )} +
+
+
+
+
+
+
+ + ); +} diff --git a/client/pages/admin/AdminModeration.tsx b/client/pages/admin/AdminModeration.tsx new file mode 100644 index 00000000..638aa086 --- /dev/null +++ b/client/pages/admin/AdminModeration.tsx @@ -0,0 +1,594 @@ +import { useState, useEffect } from "react"; +import Layout from "@/components/Layout"; +import SEO from "@/components/SEO"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Shield, + AlertTriangle, + Flag, + UserX, + CheckCircle, + XCircle, + Loader2, + Eye, + Ban, + AlertCircle, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { aethexToast } from "@/components/ui/aethex-toast"; + +interface Report { + id: string; + reporter_id: string; + target_type: string; + target_id: string; + reason: string; + details?: string; + status: string; + created_at: string; + reporter?: { + id: string; + full_name: string; + email: string; + avatar_url?: string; + }; +} + +interface Dispute { + id: string; + contract_id: string; + reason: string; + status: string; + resolution_notes?: string; + created_at: string; + reporter?: { + id: string; + full_name: string; + email: string; + }; +} + +interface FlaggedUser { + id: string; + full_name: string; + email: string; + avatar_url?: string; + is_banned: boolean; + warning_count: number; + created_at: string; +} + +interface Stats { + openReports: number; + openDisputes: number; + resolvedToday: number; + flaggedUsers: number; +} + +const getStatusColor = (status: string) => { + switch (status) { + case "open": + return "bg-red-500/20 text-red-300 border-red-500/30"; + case "resolved": + return "bg-green-500/20 text-green-300 border-green-500/30"; + case "ignored": + return "bg-slate-500/20 text-slate-300 border-slate-500/30"; + default: + return "bg-slate-500/20 text-slate-300"; + } +}; + +const getTypeColor = (type: string) => { + switch (type) { + case "post": + return "bg-blue-500/20 text-blue-300"; + case "comment": + return "bg-purple-500/20 text-purple-300"; + case "user": + return "bg-amber-500/20 text-amber-300"; + case "project": + return "bg-cyan-500/20 text-cyan-300"; + default: + return "bg-slate-500/20 text-slate-300"; + } +}; + +export default function AdminModeration() { + const { session } = useAuth(); + const [reports, setReports] = useState([]); + const [disputes, setDisputes] = useState([]); + const [flaggedUsers, setFlaggedUsers] = useState([]); + const [stats, setStats] = useState({ openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 }); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState("open"); + const [selectedReport, setSelectedReport] = useState(null); + const [selectedDispute, setSelectedDispute] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [resolution, setResolution] = useState(""); + + useEffect(() => { + if (session?.access_token) { + fetchModeration(); + } + }, [session?.access_token, statusFilter]); + + const fetchModeration = async () => { + try { + const res = await fetch(`/api/admin/moderation?status=${statusFilter}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + const data = await res.json(); + if (res.ok) { + setReports(data.reports || []); + setDisputes(data.disputes || []); + setFlaggedUsers(data.flaggedUsers || []); + setStats(data.stats || { openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 }); + } else { + aethexToast.error(data.error || "Failed to load moderation data"); + } + } catch (err) { + aethexToast.error("Failed to load moderation data"); + } finally { + setLoading(false); + } + }; + + const updateReport = async (reportId: string, status: string) => { + try { + const res = await fetch("/api/admin/moderation", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + action: "update_report", + report_id: reportId, + status, + resolution_notes: resolution + }), + }); + if (res.ok) { + aethexToast.success(`Report ${status}`); + setSelectedReport(null); + setResolution(""); + fetchModeration(); + } + } catch (err) { + aethexToast.error("Failed to update report"); + } + }; + + const updateDispute = async (disputeId: string, status: string) => { + try { + const res = await fetch("/api/admin/moderation", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + action: "update_dispute", + dispute_id: disputeId, + status, + resolution_notes: resolution + }), + }); + if (res.ok) { + aethexToast.success(`Dispute ${status}`); + setSelectedDispute(null); + setResolution(""); + fetchModeration(); + } + } catch (err) { + aethexToast.error("Failed to update dispute"); + } + }; + + const moderateUser = async (userId: string, actionType: string) => { + const reason = prompt(`Enter reason for ${actionType}:`); + if (!reason && actionType !== "unban") return; + + try { + const res = await fetch("/api/admin/moderation", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session?.access_token}`, + }, + body: JSON.stringify({ + action: "moderate_user", + user_id: userId, + action_type: actionType, + reason + }), + }); + if (res.ok) { + aethexToast.success(`User ${actionType}ned successfully`); + setSelectedUser(null); + fetchModeration(); + } + } catch (err) { + aethexToast.error(`Failed to ${actionType} user`); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + + + +
+
+
+
+
+ +
+
+ {/* Header */} +
+
+
+ +
+
+

Moderation

+

Content moderation and user management

+
+
+ +
+ + {/* Stats */} +
+ + +
+
+

Open Reports

+

{stats.openReports}

+
+ +
+
+
+ + +
+
+

Open Disputes

+

{stats.openDisputes}

+
+ +
+
+
+ + +
+
+

Resolved Today

+

{stats.resolvedToday}

+
+ +
+
+
+ + +
+
+

Flagged Users

+

{stats.flaggedUsers}

+
+ +
+
+
+
+ + {/* Tabs */} + + + Reports ({reports.length}) + Disputes ({disputes.length}) + Flagged Users ({flaggedUsers.length}) + + + {/* Reports Tab */} + + {reports.map((report) => ( + + +
+
+
+ + {report.status} + + + {report.target_type} + +
+

{report.reason}

+ {report.details && ( +

{report.details}

+ )} +
+ By: {report.reporter?.full_name || report.reporter?.email || "Unknown"} + {new Date(report.created_at).toLocaleDateString()} +
+
+ {report.status === "open" && ( +
+ + +
+ )} +
+
+
+ ))} + {reports.length === 0 && ( +
No reports found
+ )} +
+ + {/* Disputes Tab */} + + {disputes.map((dispute) => ( + + +
+
+
+ + {dispute.status} + + + Contract Dispute + +
+

{dispute.reason}

+
+ Contract: {dispute.contract_id?.slice(0, 8)}... + By: {dispute.reporter?.full_name || dispute.reporter?.email || "Unknown"} + {new Date(dispute.created_at).toLocaleDateString()} +
+
+ {dispute.status === "open" && ( + + )} +
+
+
+ ))} + {disputes.length === 0 && ( +
No disputes found
+ )} +
+ + {/* Flagged Users Tab */} + + {flaggedUsers.map((user) => ( + + +
+
+
+ {user.avatar_url ? ( + + ) : ( + {user.full_name?.[0] || "?"} + )} +
+
+

{user.full_name || "Unknown"}

+

{user.email}

+
+
+
+
+ {user.is_banned && ( + + Banned + + )} + {user.warning_count > 0 && ( + + {user.warning_count} Warnings + + )} +
+
+ {user.is_banned ? ( + + ) : ( + <> + + + + )} +
+
+
+
+
+ ))} + {flaggedUsers.length === 0 && ( +
No flagged users
+ )} +
+
+
+
+
+ + {/* Resolve Report Dialog */} + setSelectedReport(null)}> + + + Resolve Report + + {selectedReport && ( +
+
+

Reason

+

{selectedReport.reason}

+ {selectedReport.details && ( + <> +

Details

+

{selectedReport.details}

+ + )} +
+