Implement comprehensive report system with detailed viewing and AI enhancements

- Add detailed report viewing with full-screen ReportDetail component for both company and employee reports
- Fix company wiki to display onboarding Q&A in card format matching Figma designs
- Exclude company owners from employee submission counts (owners contribute to wiki, not employee data)
- Fix employee report generation to include company context (wiki + company report + employee answers)
- Fix company report generation to use filtered employee submissions only
- Add proper error handling for submission data format variations
- Update Firebase functions to use gpt-4o model instead of deprecated gpt-4.1
- Fix UI syntax errors and improve report display functionality
- Add comprehensive logging for debugging report generation flow

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ra
2025-08-18 19:08:29 -07:00
parent 557b113196
commit 1a9e92d7bd
20 changed files with 1793 additions and 635 deletions

View File

@@ -20,6 +20,158 @@ const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SEC
apiVersion: '2024-11-20.acacia',
}) : null;
const RESPONSE_FORMAT = {
type: "json_schema",
json_schema: {
name: "company_artifacts",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
report: {
type: "object",
additionalProperties: false,
properties: {
companyPerformance: {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
metrics: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
value: { anyOf: [{ type: "string" }, { type: "number" }] },
trend: { enum: ["up", "down", "flat"] }
},
required: ["name", "value", "trend"]
}
}
},
required: ["summary", "metrics"]
},
keyPersonnelChanges: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
person: { type: "string" },
change: { type: "string" }, // e.g. "Promoted to VP Eng"
impact: { type: "string" },
effectiveDate: { type: "string" }
},
required: ["person", "change", "impact", "effectiveDate"]
}
},
immediateHiringNeeds: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
role: { type: "string" },
urgency: { enum: ["low", "medium", "high"] },
reason: { type: "string" }
},
required: ["role", "urgency", "reason"]
}
},
forwardOperatingPlan: {
type: "object",
additionalProperties: false,
properties: {
nextQuarterObjectives: { type: "array", items: { type: "string" } },
initiatives: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
owner: { type: "string" },
kpis: { type: "array", items: { type: "string" } }
},
required: ["name", "owner", "kpis"]
}
},
risks: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
risk: { type: "string" },
mitigation: { type: "string" }
},
required: ["risk", "mitigation"]
}
}
},
required: ["nextQuarterObjectives", "initiatives", "risks"]
},
organizationalInsights: {
type: "object",
additionalProperties: false,
properties: {
culture: { type: "string" },
teamDynamics: { type: "string" },
blockers: { type: "array", items: { type: "string" } }
},
required: ["culture", "teamDynamics", "blockers"]
},
strengths: { type: "array", items: { type: "string" } },
gradingOverview: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
department: { type: "string" },
grade: { enum: ["A", "B", "C", "D", "F"] },
notes: { type: "string" }
},
required: ["department", "grade", "notes"]
}
}
},
required: ["companyPerformance", "keyPersonnelChanges", "immediateHiringNeeds", "forwardOperatingPlan", "organizationalInsights", "strengths", "gradingOverview"]
},
wiki: {
type: "object",
additionalProperties: false,
properties: {
companyName: { type: "string" },
industry: { type: "string" },
description: { type: "string" },
mission: { type: "string" },
values: { type: "array", items: { type: "string" } },
culture: { type: "string" },
orgInfo: {
type: "object",
additionalProperties: false,
properties: {
hq: { type: "string" },
foundedYear: { type: "number" },
headcount: { type: "number" },
products: { type: "array", items: { type: "string" } }
},
required: ["hq", "foundedYear", "headcount", "products"]
}
},
required: ["companyName", "industry", "description", "mission", "values", "culture", "orgInfo"]
}
},
required: ["report", "wiki"]
}
}
};
// Helper function to generate OTP
const generateOTP = () => {
return Math.floor(100000 + Math.random() * 900000).toString();
@@ -29,7 +181,7 @@ const generateOTP = () => {
const cors = (req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.set('Access-Control-Max-Age', '3600');
if (req.method === 'OPTIONS') {
@@ -44,7 +196,7 @@ const cors = (req, res, next) => {
const setCorsHeaders = (res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.set('Access-Control-Allow-Headers', 'Content-Type');
res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.set('Access-Control-Max-Age', '3600');
};
@@ -235,17 +387,31 @@ exports.createInvitation = functions.https.onRequest(async (req, res) => {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, email, role = "employee" } = req.body;
const { orgId, name, email, role = "employee", department } = req.body;
if (!orgId || !email) {
return res.status(400).json({ error: "Organization ID and email are required" });
if (!orgId || !email || !name) {
return res.status(400).json({ error: "Organization ID, name, and email are required" });
}
try {
// Generate invite code
const code = Math.random().toString(36).substring(2, 15);
// Generate employee ID
const employeeId = `emp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Store invitation
// Create employee object for the invite
const employee = {
id: employeeId,
name: name.trim(),
email: email.trim(),
role: role?.trim() || "employee",
department: department?.trim() || "General",
status: "invited",
inviteCode: code
};
// Store invitation with employee data
const inviteRef = await db
.collection("orgs")
.doc(orgId)
@@ -254,20 +420,29 @@ exports.createInvitation = functions.https.onRequest(async (req, res) => {
await inviteRef.set({
code,
employee,
email,
role,
orgId,
status: "pending",
createdAt: Date.now(),
expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), // 7 days
});
// Generate invite links
const baseUrl = process.env.CLIENT_URL || 'http://localhost:5174';
const inviteLink = `${baseUrl}/#/employee-form/${code}`;
const emailLink = `mailto:${email}?subject=You're invited to join our organization&body=Hi ${name},%0A%0AYou've been invited to complete a questionnaire for our organization. Please click the link below to get started:%0A%0A${inviteLink}%0A%0AThis link will expire in 7 days.%0A%0AThank you!`;
// In production, send actual invitation email
console.log(`📧 Invitation sent to ${email} with code: ${code}`);
console.log(`📧 Invitation sent to ${email} (${name}) with code: ${code}`);
console.log(`📧 Invite link: ${inviteLink}`);
res.json({
success: true,
inviteCode: code,
code,
employee,
inviteLink,
emailLink,
message: "Invitation sent successfully",
});
} catch (error) {
@@ -315,6 +490,8 @@ exports.getInvitationStatus = functions.https.onRequest(async (req, res) => {
res.json({
success: true,
used: invite.status !== 'pending',
employee: invite.employee,
invite,
});
} catch (error) {
@@ -338,8 +515,8 @@ exports.consumeInvitation = functions.https.onRequest(async (req, res) => {
const { code, userId } = req.body;
if (!code || !userId) {
return res.status(400).json({ error: "Invitation code and user ID are required" });
if (!code) {
return res.status(400).json({ error: "Invitation code is required" });
}
try {
@@ -362,25 +539,34 @@ exports.consumeInvitation = functions.https.onRequest(async (req, res) => {
return res.status(400).json({ error: "Invitation has expired" });
}
// Get employee data from the invite
const employee = invite.employee;
if (!employee) {
return res.status(400).json({ error: "Invalid invitation data - missing employee information" });
}
// Mark invitation as consumed
await inviteDoc.ref.update({
status: "consumed",
consumedBy: userId,
consumedBy: employee.id,
consumedAt: Date.now(),
});
// Add user to organization employees
// Add employee to organization using data from invite
await db
.collection("orgs")
.doc(invite.orgId)
.collection("employees")
.doc(userId)
.doc(employee.id)
.set({
id: userId,
email: invite.email,
role: invite.role,
id: employee.id,
name: employee.name || employee.email.split("@")[0],
email: employee.email,
role: employee.role || "employee",
department: employee.department || "General",
joinedAt: Date.now(),
status: "active",
inviteCode: code,
});
res.json({
@@ -407,25 +593,59 @@ exports.submitEmployeeAnswers = functions.https.onRequest(async (req, res) => {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, employeeId, answers } = req.body;
const { orgId, employeeId, answers, inviteCode } = req.body;
if (!orgId || !employeeId || !answers) {
return res.status(400).json({ error: "Organization ID, employee ID, and answers are required" });
// For invite-based submissions, we need inviteCode and answers
// For regular submissions, we need orgId, employeeId, and answers
if (inviteCode) {
if (!inviteCode || !answers) {
return res.status(400).json({ error: "Invite code and answers are required for invite submissions" });
}
} else {
if (!orgId || !employeeId || !answers) {
return res.status(400).json({ error: "Organization ID, employee ID, and answers are required" });
}
}
try {
let finalOrgId, finalEmployeeId;
if (inviteCode) {
// For invite-based submissions, look up the invite to get employee and org data
const inviteSnapshot = await db
.collectionGroup("invites")
.where("code", "==", inviteCode)
.where("status", "==", "consumed")
.limit(1)
.get();
if (inviteSnapshot.empty) {
return res.status(404).json({ error: "Invitation not found or not consumed yet" });
}
const invite = inviteSnapshot.docs[0].data();
finalOrgId = invite.orgId;
finalEmployeeId = invite.employee.id;
} else {
// Regular submission
finalOrgId = orgId;
finalEmployeeId = employeeId;
}
// Store submission
const submissionRef = await db
.collection("orgs")
.doc(orgId)
.doc(finalOrgId)
.collection("submissions")
.doc(employeeId);
.doc(finalEmployeeId);
await submissionRef.set({
employeeId,
employeeId: finalEmployeeId,
answers,
submittedAt: Date.now(),
status: "completed",
submissionType: inviteCode ? "invite" : "regular",
...(inviteCode && { inviteCode })
});
res.json({
@@ -597,62 +817,40 @@ exports.generateCompanyWiki = functions.https.onRequest(async (req, res) => {
if (openai) {
// Use OpenAI to generate the company report and wiki
const prompt = `
You are an expert business analyst. Generate a comprehensive company report and wiki based on the following data:
Organization Information:
${JSON.stringify(org, null, 2)}
Employee Submissions:
${JSON.stringify(submissions, null, 2)}
Generate a detailed analysis with two main components:
1. COMPANY REPORT with:
- companyPerformance: Overall performance metrics and trends
- keyPersonnelChanges: Recent personnel moves and their impact
- immediateHiringNeeds: Urgent staffing requirements
- forwardOperatingPlan: Strategic planning for next quarter
- organizationalInsights: Team dynamics and cultural health
- strengths: Company strengths
- gradingOverview: Performance breakdown by department
2. COMPANY WIKI with:
- companyName, industry, description
- mission, values, culture
- Key organizational information
Return ONLY valid JSON with 'report' and 'wiki' objects. Be thorough and professional.
`.trim();
const system = "You are a cut-and-dry expert business analyst. Return ONLY JSON that conforms to the provided schema.";
const user = [
"Generate a COMPANY REPORT and COMPANY WIKI that fully leverage the input data.",
"Be thorough and professional.",
"",
"Organization Information:",
JSON.stringify(org, null, 2),
"",
"Employee Submissions:",
JSON.stringify(submissions, null, 2)
].join("\n");
const completion = await openai.chat.completions.create({
model: "gpt-4o",
temperature: 0, // consistency
response_format: RESPONSE_FORMAT,
messages: [
{
role: "system",
content: "You are an expert business analyst. Generate comprehensive company reports and wikis in JSON format."
},
{
role: "user",
content: prompt
}
],
response_format: { type: "json_object" },
temperature: 0.7,
{ role: "system", content: system },
{ role: "user", content: user }
]
});
const aiResponse = completion.choices[0].message.content;
const parsedResponse = JSON.parse(aiResponse);
// content is guaranteed to be schema-conformant JSON
const parsed = JSON.parse(completion.choices[0].message.content);
report = {
const report = {
generatedAt: Date.now(),
...parsedResponse.report
...parsed.report
};
wiki = {
companyName: org.name,
const wiki = {
companyName: org?.name ?? parsed.wiki.companyName,
generatedAt: Date.now(),
...parsedResponse.wiki
...parsed.wiki,
};
} else {
// Fallback to mock data when OpenAI is not available
@@ -1451,4 +1649,49 @@ async function handlePaymentFailed(invoice) {
// } else {
// response.status(400).json({ error: 'Invalid verification code' });
// }
// });
// });
// Save Company Report Function
exports.saveCompanyReport = functions.https.onRequest(async (req, res) => {
setCorsHeaders(res);
if (req.method === 'OPTIONS') {
res.status(204).send('');
return;
}
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const { orgId, report } = req.body;
if (!orgId || !report) {
return res.status(400).json({ error: "Organization ID and report are required" });
}
try {
// Add ID and timestamp if not present
if (!report.id) {
report.id = `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
if (!report.createdAt) {
report.createdAt = Date.now();
}
// Save to Firestore
const reportRef = db.collection("orgs").doc(orgId).collection("fullCompanyReports").doc(report.id);
await reportRef.set(report);
console.log(`Company report saved successfully for org ${orgId}`);
res.json({
success: true,
reportId: report.id,
message: "Company report saved successfully"
});
} catch (error) {
console.error("Save company report error:", error);
res.status(500).json({ error: "Failed to save company report" });
}
});