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:
@@ -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" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user