Files
auditly/src/contexts/OrgContext.tsx
2025-09-22 20:05:51 -07:00

695 lines
27 KiB
TypeScript

import React, { createContext, useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { useAuth } from './AuthContext';
import { Employee, EmployeeReport, Submission, CompanyReport } from '../types';
import { User } from 'firebase/auth';
import { secureApi } from '../services/secureApi';
interface OrgData {
orgId: string;
name?: string;
companyName?: string;
onboardingData?: Record<string, any>;
companyLogo?: string;
updatedAt?: number;
onboardingCompleted?: boolean;
ownerId?: string;
ownerInfo?: {
id: string;
name: string;
email: string;
joinedAt: number;
};
}
interface OrgContextType {
org: OrgData | null;
user?: User;
orgId: string;
employees: Employee[];
submissions: Submission[];
reports: Record<string, EmployeeReport>;
loading: boolean;
upsertOrg: (data: Partial<OrgData>) => Promise<void>;
saveReport: (employeeId: string, report: EmployeeReport) => Promise<void>;
inviteEmployee: (args: { name: string; email: string }) => Promise<{ employeeId: string; inviteLink: string }>;
issueInviteViaApi: (args: { name: string; email: string; role?: string; department?: string }) => Promise<{ code: string; inviteLink: string; employee: any }>;
getInviteStatus: (code: string) => Promise<{ used: boolean; employee: any } | null>;
consumeInvite: (code: string) => Promise<{ employee: any; orgId?: string } | null>;
getReportVersions: (employeeId: string) => Promise<Array<{ id: string; createdAt: number; report: EmployeeReport }>>;
saveReportVersion: (employeeId: string, report: EmployeeReport) => Promise<void>;
acceptInvite: (code: string) => Promise<void>;
saveCompanyReport: (summary: string) => Promise<void>;
getCompanyReportHistory: () => Promise<Array<{ id: string; createdAt: number; summary: string }>>;
saveFullCompanyReport: (report: CompanyReport) => Promise<void>;
getFullCompanyReportHistory: () => Promise<CompanyReport[]>;
generateCompanyReport: () => Promise<CompanyReport>;
generateCompanyWiki: (orgOverride?: OrgData) => Promise<CompanyReport>;
seedInitialData: () => Promise<void>;
isOwner: (employeeId?: string) => boolean;
submitEmployeeAnswers: (employeeId: string, answers: Record<string, string>) => Promise<any>;
generateEmployeeReport: (employee: Employee) => Promise<EmployeeReport | null>;
getEmployeeReport: (employeeId: string) => Promise<{ success: boolean; report?: EmployeeReport; error?: string }>;
getEmployeeReports: () => Promise<{ success: boolean; reports?: EmployeeReport[]; error?: string }>;
}
const OrgContext = createContext<OrgContextType | undefined>(undefined);
export const OrgProvider: React.FC<{ children: React.ReactNode; selectedOrgId: string }> = ({ children, selectedOrgId }) => {
const { user } = useAuth();
const [org, setOrg] = useState<OrgData | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [reports, setReports] = useState<Record<string, EmployeeReport>>({});
const [reportVersions, setReportVersions] = useState<Record<string, Array<{ id: string; createdAt: number; report: EmployeeReport }>>>({});
const [companyReports, setCompanyReports] = useState<Array<{ id: string; createdAt: number; summary: string }>>([]);
const [fullCompanyReports, setFullCompanyReports] = useState<CompanyReport[]>([]);
const [loading, setLoading] = useState(true);
// Use the provided selectedOrgId instead of deriving from user
const orgId = selectedOrgId;
// Load initial data using secure API - memoized to prevent unnecessary re-runs
useEffect(() => {
if (!orgId || !user?.uid) {
setLoading(false);
return;
}
console.log('OrgContext: Loading data via secure API for orgId:', orgId);
const loadOrgData = async () => {
try {
setLoading(true);
// Batch all API calls for better performance
const [orgData, employeesData, submissionsData, reportsData, companyReportsData] = await Promise.allSettled([
secureApi.getOrgData().catch(() => null),
secureApi.getEmployees().catch(() => []),
secureApi.getSubmissions().catch(() => ([])),
secureApi.getReports().catch(() => ({})),
secureApi.getCompanyReports().catch(() => [])
]);
// Process organization data
if (orgData.status === 'fulfilled' && orgData.value) {
setOrg({ orgId, ...orgData.value });
} else {
console.warn('Could not load org data, creating default');
const defaultOrg = { name: 'Your Company', onboardingCompleted: false };
await secureApi.updateOrgData(defaultOrg);
setOrg({ orgId, ...defaultOrg });
}
// Process employees data
if (employeesData.status === 'fulfilled') {
setEmployees(employeesData.value.map(emp => ({
...emp,
initials: emp.name ? emp.name.split(' ').map(n => n[0]).join('').toUpperCase() : emp.email?.substring(0, 2).toUpperCase() || 'U'
})));
} else {
console.warn('Could not load employees');
setEmployees([]);
}
// Process submissions data
if (submissionsData.status === 'fulfilled') {
setSubmissions(submissionsData.value);
} else {
console.warn('Could not load submissions');
setSubmissions([]);
}
// Process reports data
if (reportsData.status === 'fulfilled') {
setReports(reportsData.value as Record<string, EmployeeReport>);
} else {
console.warn('Could not load reports');
setReports({});
}
// Process company reports data
if (companyReportsData.status === 'fulfilled') {
setFullCompanyReports(companyReportsData.value);
} else {
console.warn('Could not load company reports');
setFullCompanyReports([]);
}
} catch (error) {
console.error('Failed to load org data:', error);
} finally {
setLoading(false);
}
};
loadOrgData();
}, [orgId, user?.uid]); // Only re-run when orgId or user changes
const upsertOrg = useCallback(async (data: Partial<OrgData>) => {
if (!user?.uid) {
throw new Error('User authentication required');
}
try {
await secureApi.updateOrgData(data);
// Update local state
const updatedOrg = { ...(org || { orgId, name: 'Your Company' }), ...data } as OrgData;
setOrg(updatedOrg);
// If onboarding was completed, notify other contexts
if (data.onboardingCompleted) {
console.log('OrgContext: Onboarding completed, dispatching update event', {
orgId: updatedOrg.orgId,
onboardingCompleted: true
});
window.dispatchEvent(new CustomEvent('organizationUpdated', {
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
}));
}
} catch (error) {
console.error('Failed to update organization:', error);
throw error;
}
}, [user?.uid, org, orgId]);
const saveReport = useCallback(async (employeeId: string, report: EmployeeReport) => {
if (!user?.uid) {
throw new Error('User authentication required');
}
try {
const savedReport = await secureApi.saveReport(employeeId, report);
// Update local state
setReports(prev => ({ ...prev, [employeeId]: savedReport }));
} catch (error) {
console.error('Failed to save report:', error);
throw error;
}
}, [user?.uid]);
const inviteEmployee = async ({ name, email, role, department }: { name: string; email: string, role?: string, department?: string }) => {
console.log('inviteEmployee called:', { name, email, orgId });
try {
// Use secure API for invites
const data = await secureApi.createInvitation({ name, email, role, department });
console.log('Invite created successfully:', { code: data.code, employee: data.employee.name, inviteLink: data.inviteLink });
// Store employee locally for immediate UI update with proper typing
const newEmployee: Employee = {
id: data.employee.id,
name: data.employee.name,
email: data.employee.email,
initials: data.employee.name ? data.employee.name.split(' ').map(n => n[0]).join('').toUpperCase() : data.employee.email.substring(0, 2).toUpperCase(),
department: data.employee.department,
role: data.employee.role,
status: data.employee.status
};
// Add to local state for immediate UI update
setEmployees(prev => {
if (prev.find(e => e.id === data.employee.id)) return prev;
return [...prev, newEmployee];
});
return { employeeId: data.employee.id, inviteLink: data.inviteLink };
} catch (error) {
console.error('inviteEmployee error:', error);
throw error;
}
};
const getReportVersions = async (employeeId: string) => {
// This feature is not yet implemented in secure API
// Return empty array for now until we add version support to cloud functions
console.warn('Report versions not yet supported in secure API');
return [];
};
const saveReportVersion = async (employeeId: string, report: EmployeeReport) => {
// This feature is not yet implemented in secure API
console.warn('Report versions not yet supported in secure API');
const version = { id: Date.now().toString(), createdAt: Date.now(), report };
setReportVersions(prev => ({ ...prev, [employeeId]: [version, ...(prev[employeeId] || [])] }));
};
const acceptInvite = async (code: string) => {
if (!code || !user?.uid) return;
try {
await secureApi.consumeInvitation(code, user.uid);
} catch (error) {
console.error('Failed to accept invite:', error);
throw error;
}
};
const saveCompanyReport = async (summary: string) => {
if (!user?.uid) {
throw new Error('User authentication required');
}
try {
const report = {
id: Date.now().toString(),
createdAt: Date.now(),
summary
};
await secureApi.saveCompanyReport(report);
// Update local state
setCompanyReports(prev => [{ id: report.id, createdAt: report.createdAt, summary }, ...prev]);
} catch (error) {
console.error('Failed to save company report:', error);
throw error;
}
};
const getCompanyReportHistory = async () => {
if (!user?.uid) {
throw new Error('User authentication required');
}
try {
const reports = await secureApi.getCompanyReports();
return reports.map(report => ({
id: report.id,
createdAt: report.createdAt || 0,
summary: report.executiveSummary || ''
})).sort((a, b) => b.createdAt - a.createdAt);
} catch (error) {
console.error('Failed to get company report history:', error);
return [];
}
};
const seedInitialData = async () => {
// Start with clean slate - let users invite their own employees and generate real data
setEmployees([]);
setSubmissions([]);
setReports({});
setFullCompanyReports([]);
};
const saveFullCompanyReport = async (report: CompanyReport) => {
if (!orgId) {
console.error('Cannot save company report: orgId is undefined');
throw new Error('Organization ID is required to save company report');
}
if (!user?.uid) {
throw new Error('User authentication required');
}
try {
await secureApi.saveCompanyReport(report);
// Update local state after successful save
setFullCompanyReports(prev => [report, ...prev]);
} catch (error) {
console.error('Failed to save full company report:', error);
throw error;
}
};
const getFullCompanyReportHistory = async (): Promise<CompanyReport[]> => {
if (!user?.uid) {
throw new Error('User authentication required');
}
try {
const reports = await secureApi.getCompanyReports();
return reports.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
} catch (error) {
console.error('Failed to get full company report history:', error);
return [];
}
};
const generateCompanyReport = async (): Promise<CompanyReport> => {
console.log('generateCompanyReport called for org:', orgId);
if (!user?.uid) {
throw new Error('User authentication required');
}
// Calculate concrete metrics from actual data (no AI needed)
// Employees collection only contains actual employees (owners are not in this collection)
const actualEmployees = employees;
const totalEmployees = actualEmployees.length;
// Count submissions from employees
const employeeSubmissions = submissions;
const submittedEmployees = Object.keys(employeeSubmissions).length;
const submissionRate = totalEmployees > 0 ? (submittedEmployees / totalEmployees) * 100 : 0;
// Department breakdown (concrete data) - exclude owners
const deptMap = new Map<string, number>();
actualEmployees.forEach(emp => {
const dept = emp.department || 'Unassigned';
deptMap.set(dept, (deptMap.get(dept) || 0) + 1);
});
const departmentBreakdown = Array.from(deptMap.entries()).map(([department, count]) => ({ department, count }));
let response, report: CompanyReport;
try {
// Use secure API for AI generation
response = await secureApi.generateCompanyWiki({
...org,
metrics: {
totalEmployees,
submissionRate,
departmentBreakdown
}
}, Object.values(employeeSubmissions));
console.log('Company insights generated via AI successfully');
// Combine concrete metrics with AI insights
// report = {
// id: Date.now().toString(),
// createdAt: Date.now(),
// // Use AI-generated insights for subjective analysis
// // Override with our concrete metrics
// overview: {
// totalEmployees,
// departmentBreakdown,
// submissionRate,
// lastUpdated: Date.now(),
// averagePerformanceScore: (response as any)?.overview?.averagePerformanceScore || 0,
// riskLevel: (response as any)?.overview?.riskLevel || 'Unknown'
// },
// ...(response as any)
// };
console.log('Final company report object:', report);
// await saveFullCompanyReport(report);
return response;
} catch (error) {
console.error('generateCompanyReport error:', error);
throw error;
}
};
const generateCompanyWiki = async (orgOverride?: OrgData): Promise<CompanyReport> => {
const orgData = orgOverride || org;
console.log('generateCompanyWiki called with:', { orgData, orgId, submissionsCount: Object.keys(submissions || {}).length });
if (!orgId) {
throw new Error('Organization ID is required to generate company wiki');
}
if (!user?.uid) {
throw new Error('User authentication required');
}
// Use secure API for wiki generation
try {
console.log('Making API call to generateCompanyWiki...');
let response = await secureApi.generateCompanyWiki(orgData, Object.values(submissions || {}));
console.log('API success response:', response);
// Ensure the report has all required fields to prevent undefined errors
const report: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
overview: {
totalEmployees: employees.length,
departmentBreakdown: [],
submissionRate: 0,
lastUpdated: Date.now(),
averagePerformanceScore: 0,
riskLevel: 'Unknown'
},
gradingBreakdown: [],
personnelChanges: { newHires: [], promotions: [], departures: [] },
immediateHiringNeeds: [],
forwardOperatingPlan: { quarterlyGoals: [], resourceNeeds: [], riskMitigation: [] },
executiveSummary: 'Company report generated successfully.',
// Override with API data if available
...(response as any || {})
};
// await saveFullCompanyReport(data);
return response;
} catch (e) {
console.error('generateCompanyWiki error, falling back to local synthetic:', e);
return generateCompanyReport();
}
};
const isOwner = (userId?: string): boolean => {
// Check if the given user ID matches the org owner ID
// If no userId provided, check current user
const targetUserId = userId || user?.uid;
return targetUserId === org?.ownerId;
};
const getEmployeeReport = async (employeeId: string) => {
try {
if (!user?.uid) {
throw new Error('User authentication required');
}
// Use secure API for all employee report operations
const report = await secureApi.getReports();
const employeeReport = report[employeeId];
if (employeeReport) {
return { success: true, report: employeeReport };
}
return { success: false, error: 'Report not found' };
} catch (error) {
console.error('Error fetching employee report:', error);
return { success: false, error: (error as Error).message };
}
};
const getEmployeeReports = async () => {
try {
if (!user?.uid) {
throw new Error('User authentication required');
}
// Use secure API for all employee report operations
const reportsData = await secureApi.getReports();
const reports = Object.values(reportsData);
return { success: true, reports };
} catch (error) {
console.error('Error fetching employee reports:', error);
return { success: false, error: (error as Error).message };
}
};
// Memoize functions that don't need dependencies
const issueInviteViaApi = useCallback(async ({ name, email, role, department }) => {
try {
if (!user?.uid) {
throw new Error('User authentication required');
}
const data = await secureApi.createInvitation({ name, email, role, department });
// Optimistically add employee shell (not yet active until consume)
setEmployees(prev => prev.find(e => e.id === data.employee.id) ? prev : [...prev, {
...data.employee,
initials: data.employee.name ? data.employee.name.split(' ').map((n: string) => n[0]).join('').toUpperCase() : data.employee.email.substring(0, 2).toUpperCase()
} as Employee]);
return data;
} catch (e) {
console.error('issueInviteViaApi error', e);
throw e;
}
}, [user?.uid]);
const getInviteStatus = useCallback(async (code: string) => {
try {
return await secureApi.getInvitationStatus(code);
} catch (e) {
console.error('getInviteStatus error', e);
return null;
}
}, []);
const consumeInvite = useCallback(async (code: string) => {
try {
if (!user?.uid) {
throw new Error('User authentication required');
}
const result = await secureApi.consumeInvitation(code, user.uid);
// Mark employee as active
if (result && (result as any).employee) {
setEmployees(prev => prev.find(e => e.id === (result as any).employee.id) ? prev : [...prev, (result as any).employee]);
return { ...(result as any), orgId: org?.orgId };
}
return null;
} catch (e) {
console.error('consumeInvite error', e);
return null;
}
}, [user?.uid, org?.orgId]);
const submitEmployeeAnswers = useCallback(async (employeeId: string, answers: Record<string, string>) => {
try {
// Use secure API for submission
await secureApi.submitEmployeeAnswers(employeeId, answers);
// Update local state for immediate UI feedback
const convertedSubmission: Submission = {
employeeId,
answers: Object.entries(answers).map(([question, answer]) => ({
question,
answer
}))
};
setSubmissions(prev => ({ ...prev, [employeeId]: convertedSubmission }));
return true;
} catch (e) {
console.error('submitEmployeeAnswers error', e);
return false;
}
}, []);
const generateEmployeeReport = useCallback(async (employee: Employee) => {
try {
console.log('generateEmployeeReport called for:', employee.name, 'in org:', orgId);
if (!user?.uid) {
throw new Error('User authentication required');
}
// Get submission data for this employee
const submission = submissions[employee.id];
if (!submission) {
throw new Error(`No questionnaire submission found for ${employee.name}. Please ensure they have completed the employee questionnaire first.`);
}
// Convert submission format for API
let submissionAnswers: Record<string, string> = {};
if (submission.answers) {
if (Array.isArray(submission.answers)) {
// If answers is an array of {question, answer} objects
submissionAnswers = submission.answers.reduce((acc, item: any) => {
acc[item.question] = item.answer;
return acc;
}, {} as Record<string, string>);
} else {
// If answers is already a key-value object
submissionAnswers = submission.answers as Record<string, string>;
}
}
console.log('Submission data found:', Object.keys(submissionAnswers).length, 'answers');
// Get company report and wiki data for context
let companyWiki = null;
try {
const companyReports = await getFullCompanyReportHistory();
if (companyReports.length > 0) {
companyWiki = {
org: org,
companyReport: companyReports[0]
};
console.log('Including company context in employee report generation');
}
} catch (error) {
console.warn('Could not fetch company report for context:', error);
}
const data = await secureApi.generateEmployeeReport(employee, submissionAnswers, companyWiki);
if ((data as any).report) {
console.log('Employee report generated successfully');
const report = (data as any).report as EmployeeReport;
setReports(prev => ({ ...prev, [employee.id]: report }));
return report;
} else {
throw new Error('No report data received from API');
}
} catch (e) {
console.error('generateEmployeeReport error', e);
throw e; // Re-throw to allow caller to handle
}
}, [user?.uid, orgId, submissions, org, getFullCompanyReportHistory]);
// Memoize the entire context value to prevent unnecessary re-renders
const value = useMemo(() => ({
org,
orgId,
employees,
submissions,
reports,
loading,
upsertOrg,
saveReport,
inviteEmployee,
getReportVersions,
saveReportVersion,
acceptInvite,
saveCompanyReport,
getCompanyReportHistory,
saveFullCompanyReport,
getFullCompanyReportHistory,
generateCompanyReport,
generateCompanyWiki,
seedInitialData,
isOwner,
issueInviteViaApi,
getInviteStatus,
consumeInvite,
submitEmployeeAnswers,
generateEmployeeReport,
getEmployeeReport,
getEmployeeReports,
}), [
org,
orgId,
employees,
submissions,
reports,
loading,
upsertOrg,
saveReport,
inviteEmployee,
getReportVersions,
saveReportVersion,
acceptInvite,
saveCompanyReport,
getCompanyReportHistory,
saveFullCompanyReport,
getFullCompanyReportHistory,
generateCompanyReport,
generateCompanyWiki,
seedInitialData,
isOwner,
issueInviteViaApi,
getInviteStatus,
consumeInvite,
submitEmployeeAnswers,
generateEmployeeReport,
getEmployeeReport,
getEmployeeReports,
]);
return (
<OrgContext.Provider value={value}>
{children}
</OrgContext.Provider>
);
};
export const useOrg = () => {
const ctx = useContext(OrgContext);
if (!ctx) throw new Error('useOrg must be used within OrgProvider');
return ctx;
};