Files
auditly/contexts/OrgContext.tsx

770 lines
32 KiB
TypeScript

import React, { createContext, useContext, useEffect, useState } from 'react';
import { collection, doc, getDoc, getDocs, onSnapshot, setDoc } from 'firebase/firestore';
import { db, isFirebaseConfigured } from '../services/firebase';
import { useAuth } from './AuthContext';
import { Employee, Report, Submission, CompanyReport } from '../types';
import { SAMPLE_COMPANY_REPORT, API_URL } from '../constants';
import { demoStorage } from '../services/demoStorage';
import { apiPost, apiPut } from '../services/api';
interface OrgData {
orgId: string;
name: string;
industry?: string;
size?: string;
description?: string;
mission?: string;
vision?: string;
values?: string;
foundingYear?: string;
evolution?: string;
majorMilestones?: string;
advantages?: string;
vulnerabilities?: string;
competitors?: string;
marketPosition?: string;
currentChallenges?: string;
shortTermGoals?: string;
longTermGoals?: string;
keyMetrics?: string;
cultureDescription?: string;
workEnvironment?: string;
leadershipStyle?: string;
communicationStyle?: string;
additionalContext?: string;
onboardingCompleted?: boolean;
}
interface OrgContextType {
org: OrgData | null;
orgId: string;
employees: Employee[];
submissions: Record<string, Submission>;
reports: Record<string, Report>;
upsertOrg: (data: Partial<OrgData>) => Promise<void>;
saveReport: (employeeId: string, report: Report) => 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; emailLink: string; employee: any }>;
getInviteStatus: (code: string) => Promise<{ used: boolean; employee: any } | null>;
consumeInvite: (code: string) => Promise<{ employee: any } | null>;
getReportVersions: (employeeId: string) => Promise<Array<{ id: string; createdAt: number; report: Report }>>;
saveReportVersion: (employeeId: string, report: Report) => 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<boolean>;
generateEmployeeReport: (employee: Employee) => Promise<Report | null>;
getEmployeeReport: (employeeId: string) => Promise<{ success: boolean; report?: Report; error?: string }>;
getEmployeeReports: () => Promise<{ success: boolean; reports?: Report[]; 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<Record<string, Submission>>({});
const [reports, setReports] = useState<Record<string, Report>>({});
const [reportVersions, setReportVersions] = useState<Record<string, Array<{ id: string; createdAt: number; report: Report }>>>({});
const [companyReports, setCompanyReports] = useState<Array<{ id: string; createdAt: number; summary: string }>>([]);
const [fullCompanyReports, setFullCompanyReports] = useState<CompanyReport[]>([]);
// Use the provided selectedOrgId instead of deriving from user
const orgId = selectedOrgId;
useEffect(() => {
console.log('OrgContext effect running, orgId:', orgId, 'isFirebaseConfigured:', isFirebaseConfigured);
if (!orgId) return; // Wait for orgId to be available
console.log('Setting up Firebase org data');
const orgRef = doc(db, 'orgs', orgId);
getDoc(orgRef).then(async (snap) => {
if (snap.exists()) {
setOrg({ orgId, ...(snap.data() as any) });
} else {
const seed = { name: 'Your Company', onboardingCompleted: false };
await setDoc(orgRef, seed);
setOrg({ orgId, ...(seed as any) });
}
});
const employeesCol = collection(db, 'orgs', orgId, 'employees');
const unsubEmp = onSnapshot(employeesCol, (snap) => {
const arr: Employee[] = [];
snap.forEach((d) => arr.push({ id: d.id, ...(d.data() as any) }));
setEmployees(arr);
});
const submissionsCol = collection(db, 'orgs', orgId, 'submissions');
const unsubSub = onSnapshot(submissionsCol, (snap) => {
const map: Record<string, Submission> = {};
snap.forEach((d) => (map[d.id] = { employeeId: d.id, ...(d.data() as any) }));
setSubmissions(map);
});
const reportsCol = collection(db, 'orgs', orgId, 'reports');
const unsubRep = onSnapshot(reportsCol, (snap) => {
const map: Record<string, Report> = {};
snap.forEach((d) => (map[d.id] = { employeeId: d.id, ...(d.data() as any) } as Report));
setReports(map);
});
return () => { unsubEmp(); unsubSub(); unsubRep(); };
}, [orgId]);
const upsertOrg = async (data: Partial<OrgData>) => {
const orgRef = doc(db, 'orgs', orgId);
await setDoc(orgRef, data, { merge: true });
// 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 (Firebase): Onboarding completed, dispatching update event', {
orgId: updatedOrg.orgId,
onboardingCompleted: true
});
window.dispatchEvent(new CustomEvent('organizationUpdated', {
detail: { orgId: updatedOrg.orgId, onboardingCompleted: true }
}));
}
};
const saveReport = async (employeeId: string, report: Report) => {
const ref = doc(db, 'orgs', orgId, 'reports', employeeId);
await setDoc(ref, report, { merge: true });
};
const inviteEmployee = async ({ name, email }: { name: string; email: string }) => {
console.log('inviteEmployee called:', { name, email, orgId });
try {
// Always use Cloud Functions for invites to ensure multi-tenant compliance
const res = await apiPost('/createInvitation', {
name,
email
}, orgId);
if (!res.ok) {
const errorData = await res.json();
console.error('Invite creation failed:', errorData);
throw new Error(errorData.error || `Failed to create invite: ${res.status}`);
}
const data = await res.json();
const { code, employee, inviteLink } = data;
console.log('Invite created successfully:', { code, employee: employee.name, inviteLink });
// Store employee locally for immediate UI update with proper typing
const newEmployee: Employee = {
id: employee.id,
name: employee.name,
email: employee.email,
initials: employee.name ? employee.name.split(' ').map(n => n[0]).join('').toUpperCase() : employee.email.substring(0, 2).toUpperCase(),
department: employee.department,
role: employee.role,
isOwner: false
};
if (!isFirebaseConfigured) {
const employeeWithOrg = { ...newEmployee, orgId };
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
demoStorage.saveEmployee(employeeWithOrg);
} else {
// For Firebase, add to local state for immediate UI update
setEmployees(prev => {
if (prev.find(e => e.id === employee.id)) return prev;
return [...prev, newEmployee];
});
}
return { employeeId: employee.id, inviteLink };
} catch (error) {
console.error('inviteEmployee error:', error);
throw error;
}
};
const getReportVersions = async (employeeId: string) => {
if (!isFirebaseConfigured) {
return reportVersions[employeeId] || [];
}
const col = collection(db, 'orgs', orgId, 'reports', employeeId, 'versions');
const snap = await getDocs(col);
const arr: Array<{ id: string; createdAt: number; report: Report }> = [];
snap.forEach(d => {
const data = d.data() as any;
arr.push({ id: d.id, createdAt: data.createdAt ?? 0, report: data.report as Report });
});
return arr.sort((a, b) => b.createdAt - a.createdAt);
};
const saveReportVersion = async (employeeId: string, report: Report) => {
const version = { id: Date.now().toString(), createdAt: Date.now(), report };
if (!isFirebaseConfigured) {
setReportVersions(prev => ({ ...prev, [employeeId]: [version, ...(prev[employeeId] || [])] }));
return;
}
const ref = doc(db, 'orgs', orgId, 'reports', employeeId, 'versions', version.id);
await setDoc(ref, { createdAt: version.createdAt, report });
};
const acceptInvite = async (code: string) => {
if (!code) return;
if (!isFirebaseConfigured) {
// Demo mode: mark invite as used
demoStorage.markInviteUsed(code);
return;
}
const inviteRef = doc(db, 'orgs', orgId, 'invites', code);
const snap = await getDoc(inviteRef);
if (!snap.exists()) return;
const data = snap.data() as any;
// Minimal: mark accepted
await setDoc(inviteRef, { ...data, acceptedAt: Date.now() }, { merge: true });
};
const saveCompanyReport = async (summary: string) => {
const id = Date.now().toString();
const createdAt = Date.now();
if (!isFirebaseConfigured) {
const reportData = { id, createdAt, summary };
setCompanyReports(prev => [reportData, ...prev]);
// Persist to localStorage (note: this method stores simple reports)
return;
}
const ref = doc(db, 'orgs', orgId, 'companyReports', id);
await setDoc(ref, { createdAt, summary });
};
const getCompanyReportHistory = async () => {
if (!isFirebaseConfigured) {
return companyReports;
}
const col = collection(db, 'orgs', orgId, 'companyReports');
const snap = await getDocs(col);
const arr: Array<{ id: string; createdAt: number; summary: string }> = [];
snap.forEach(d => {
const data = d.data() as any;
arr.push({ id: d.id, createdAt: data.createdAt ?? 0, summary: data.summary ?? '' });
});
return arr.sort((a, b) => b.createdAt - a.createdAt);
};
const seedInitialData = async () => {
if (!isFirebaseConfigured) {
// Start with completely clean slate - no sample data
setEmployees([]);
setSubmissions({});
setReports({});
setFullCompanyReports([]);
return;
}
// Start with clean slate - let users invite their own employees and generate real data
};
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 (!isFirebaseConfigured || !db) {
// Fallback to local storage in demo mode
setFullCompanyReports(prev => [report, ...prev]);
demoStorage.saveCompanyReport(orgId, report);
return;
}
// Use direct Firestore operations - much more efficient
const ref = doc(db, 'orgs', orgId, 'fullCompanyReports', report.id);
await setDoc(ref, report);
// Update local state after successful save
setFullCompanyReports(prev => [report, ...prev]);
};
const getFullCompanyReportHistory = async (): Promise<CompanyReport[]> => {
if (!isFirebaseConfigured) {
return fullCompanyReports;
}
const col = collection(db, 'orgs', orgId, 'fullCompanyReports');
const snap = await getDocs(col);
const arr: CompanyReport[] = [];
snap.forEach(d => {
arr.push({ id: d.id, ...d.data() } as CompanyReport);
});
return arr.sort((a, b) => b.createdAt - a.createdAt);
};
const generateCompanyReport = async (): Promise<CompanyReport> => {
console.log('generateCompanyReport called for org:', orgId);
// Calculate concrete metrics from actual data (no AI needed)
// Exclude owners from employee counts - they are company wiki contributors, not employees
const actualEmployees = employees.filter(emp => !emp.isOwner);
const totalEmployees = actualEmployees.length;
// Only count submissions from non-owner employees
const employeeSubmissions = Object.fromEntries(
Object.entries(submissions).filter(([employeeId]) => {
const employee = employees.find(emp => emp.id === employeeId);
return employee && !employee.isOwner;
})
);
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 }));
try {
// Use AI only for analysis and insights that require reasoning
const res = await apiPost('/generateCompanyWiki', {
org: org,
submissions: employeeSubmissions, // Only employee submissions, not owner data
metrics: {
totalEmployees,
submissionRate,
departmentBreakdown
}
}, orgId);
if (!res.ok) {
const errorData = await res.json();
console.error('Company report generation failed:', errorData);
throw new Error(errorData.error || 'Failed to generate company report');
}
const data = await res.json();
console.log('Company insights generated via AI successfully');
console.log('AI response data:', data);
// Combine concrete metrics with AI insights
const report: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
// Use AI-generated insights for subjective analysis
...data.report,
// Override with our concrete metrics
overview: {
totalEmployees,
departmentBreakdown,
submissionRate,
lastUpdated: Date.now(),
averagePerformanceScore: data.report?.overview?.averagePerformanceScore || 0,
riskLevel: data.report?.overview?.riskLevel || 'Unknown'
}
};
console.log('Final company report object:', report);
await saveFullCompanyReport(report);
return report;
} 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, isFirebaseConfigured });
if (!orgId) {
throw new Error('Organization ID is required to generate company wiki');
}
// ALWAYS use API call for wiki generation, with local fallback
try {
console.log('Making API call to generateCompanyWiki...');
const res = await apiPost('/generateCompanyWiki', {
org: orgData,
submissions: submissions || []
}, orgId);
console.log('API response status:', res.status);
if (!res.ok) {
const errorData = await res.json();
console.error('API error response:', errorData);
throw new Error(errorData.error || 'Failed to generate company wiki');
}
const payload = await res.json();
console.log('API success response:', payload);
// Ensure the report has all required fields to prevent undefined errors
const data: CompanyReport = {
id: Date.now().toString(),
createdAt: Date.now(),
overview: {
totalEmployees: employees.length,
departmentBreakdown: [],
submissionRate: 0,
lastUpdated: Date.now(),
averagePerformanceScore: 0,
riskLevel: 'Unknown'
},
gradingBreakdown: [],
operatingPlan: { nextQuarterGoals: [], keyInitiatives: [], resourceNeeds: [], riskMitigation: [] },
personnelChanges: { newHires: [], promotions: [], departures: [] },
keyPersonnelChanges: [],
immediateHiringNeeds: [],
forwardOperatingPlan: { quarterlyGoals: [], resourceNeeds: [], riskMitigation: [] },
organizationalStrengths: [],
organizationalRisks: [],
gradingOverview: {},
executiveSummary: 'Company report generated successfully.',
// Override with API data if available
...(payload.report || payload)
};
await saveFullCompanyReport(data);
return data;
} catch (e) {
console.error('generateCompanyWiki error, falling back to local synthetic:', e);
return generateCompanyReport();
}
};
const isOwner = (employeeId?: string): boolean => {
const currentEmployee = employeeId ? employees.find(e => e.id === employeeId) :
employees.find(e => e.email === user?.email);
return currentEmployee?.isOwner === true;
};
const getEmployeeReport = async (employeeId: string) => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportDoc = await getDoc(doc(db, 'organizations', orgId, 'employeeReports', employeeId));
if (reportDoc.exists()) {
return { success: true, report: reportDoc.data() };
}
return { success: false, error: 'Report not found' };
} else {
// Demo mode - call API
const response = await fetch(`${API_URL}/api/employee-report/${employeeId}`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee report:', error);
return { success: false, error: error.message };
}
};
const getEmployeeReports = async () => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportsSnapshot = await getDocs(collection(db, 'organizations', orgId, 'employeeReports'));
const reports = reportsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
return { success: true, reports };
} else {
// Demo mode - call API
const response = await fetch(`${API_URL}/api/employee-reports`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee reports:', error);
return { success: false, error: error.message };
}
};
const value = {
org,
orgId,
employees,
submissions,
reports,
upsertOrg,
saveReport,
inviteEmployee,
getReportVersions,
saveReportVersion,
acceptInvite,
saveCompanyReport,
getCompanyReportHistory,
saveFullCompanyReport,
getFullCompanyReportHistory,
generateCompanyReport,
generateCompanyWiki,
seedInitialData,
isOwner,
issueInviteViaApi: async ({ name, email, role, department }) => {
try {
const res = await apiPost('/createInvitation', {
name,
email,
role,
department
}, orgId);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Invite creation failed');
}
const json = await res.json();
// Optimistically add employee shell (not yet active until consume)
setEmployees(prev => prev.find(e => e.id === json.employee.id) ? prev : [...prev, { ...json.employee }]);
return json;
} catch (e) {
console.error('issueInviteViaApi error', e);
throw e;
}
},
getInviteStatus: async (code: string) => {
if (!isFirebaseConfigured) {
// Demo mode: check localStorage first, then server
const invite = demoStorage.getInvite(code);
if (invite) {
return { used: invite.used, employee: invite.employee };
}
}
try {
const res = await fetch(`${API_URL}/getInvitationStatus?code=${code}`);
if (!res.ok) return null;
return await res.json();
} catch (e) {
console.error('getInviteStatus error', e);
return null;
}
},
consumeInvite: async (code: string) => {
if (!isFirebaseConfigured) {
// Demo mode: mark invite as used in localStorage and update state
const invite = demoStorage.getInvite(code);
if (invite && !invite.used) {
demoStorage.markInviteUsed(code);
// Ensure employee is in the list with proper typing
const convertedEmployee: Employee = {
id: invite.employee.id,
name: invite.employee.name,
email: invite.employee.email,
initials: invite.employee.name ? invite.employee.name.split(' ').map(n => n[0]).join('').toUpperCase() : invite.employee.email.substring(0, 2).toUpperCase(),
department: invite.employee.department,
role: invite.employee.role
};
setEmployees(prev => prev.find(e => e.id === invite.employee.id) ? prev : [...prev, convertedEmployee]);
return { employee: convertedEmployee };
}
return null;
}
try {
const res = await fetch(`${API_URL}/consumeInvitation?code=${code}`, { method: 'POST' });
if (!res.ok) return null;
const json = await res.json();
// Mark employee as active (could set a flag later)
setEmployees(prev => prev.find(e => e.id === json.employee.id) ? prev : [...prev, json.employee]);
return json;
} catch (e) {
console.error('consumeInvite error', e);
return null;
}
},
submitEmployeeAnswers: async (employeeId: string, answers: Record<string, string>) => {
if (!isFirebaseConfigured) {
// Demo mode: save to localStorage and call server endpoint
try {
const submission = {
employeeId,
orgId,
answers,
createdAt: Date.now()
};
// Save to localStorage for persistence
demoStorage.saveSubmission(submission);
// Also call Cloud Function for processing with authentication and orgId
const employee = employees.find(e => e.id === employeeId);
const res = await apiPost('/submitEmployeeAnswers', {
employeeId,
answers,
employee
}, orgId);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Failed to submit to server');
}
// Update local state for UI with proper typing
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;
}
}
// Firebase mode: save to Firestore
try {
const ref = doc(db, 'orgs', orgId, 'submissions', employeeId);
await setDoc(ref, { ...answers, createdAt: Date.now() }, { merge: true });
return true;
} catch (e) {
console.error('submitEmployeeAnswers error', e);
return false;
}
},
generateEmployeeReport: async (employee: Employee) => {
try {
console.log('generateEmployeeReport called for:', employee.name, 'in org:', orgId);
// 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) => {
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 res = await apiPost('/generateEmployeeReport', {
employee,
submission: submissionAnswers,
companyWiki
}, orgId);
if (!res.ok) {
const errorData = await res.json();
console.error('API error response:', errorData);
throw new Error(errorData.error || 'Failed to generate employee report');
}
const json = await res.json();
if (json.report) {
console.log('Employee report generated successfully');
setReports(prev => ({ ...prev, [employee.id]: json.report }));
// Also save to persistent storage in demo mode
if (!isFirebaseConfigured) {
demoStorage.saveEmployeeReport(orgId, employee.id, json.report);
}
return json.report as 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
}
},
getEmployeeReport: async (employeeId: string) => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportDoc = await getDoc(doc(db, 'organizations', orgId, 'employeeReports', employeeId));
if (reportDoc.exists()) {
return { success: true, report: reportDoc.data() };
}
return { success: false, error: 'Report not found' };
} else {
// Demo mode - call Cloud Function
const response = await fetch(`${API_URL}/generateEmployeeReport?employeeId=${employeeId}`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee report:', error);
return { success: false, error: error.message };
}
},
getEmployeeReports: async () => {
try {
if (isFirebaseConfigured && user) {
// Firebase implementation
const reportsSnapshot = await getDocs(collection(db, 'organizations', orgId, 'employeeReports'));
const reports = reportsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
return { success: true, reports };
} else {
// Demo mode - call Cloud Function
const response = await fetch(`${API_URL}/generateEmployeeReport`);
const result = await response.json();
return result;
}
} catch (error) {
console.error('Error fetching employee reports:', error);
return { success: false, error: error.message };
}
},
};
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;
};