1454 lines
48 KiB
JavaScript
1454 lines
48 KiB
JavaScript
const { onRequest } = require("firebase-functions/v2/https");
|
|
const admin = require("firebase-admin");
|
|
const serviceAccount = require("./auditly-c0027-firebase-adminsdk-fbsvc-1db7c58141.json");
|
|
const functions = require("firebase-functions");
|
|
const OpenAI = require("openai");
|
|
const Stripe = require("stripe");
|
|
|
|
admin.initializeApp({
|
|
credential: admin.credential.cert(serviceAccount)
|
|
});
|
|
const db = admin.firestore();
|
|
|
|
// Initialize OpenAI if API key is available
|
|
const openai = process.env.OPENAI_API_KEY ? new OpenAI({
|
|
apiKey: process.env.OPENAI_API_KEY,
|
|
}) : null;
|
|
|
|
// Initialize Stripe if API key is available
|
|
const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY, {
|
|
apiVersion: '2024-11-20.acacia',
|
|
}) : null;
|
|
|
|
// Helper function to generate OTP
|
|
const generateOTP = () => {
|
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
|
};
|
|
|
|
// CORS middleware
|
|
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-Max-Age', '3600');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.status(204).send('');
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
// Helper function to set CORS headers
|
|
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-Max-Age', '3600');
|
|
};
|
|
|
|
// Send OTP Function
|
|
exports.sendOTP = functions.https.onRequest((req, res) => {
|
|
cors(req, res, async () => {
|
|
if (req.method !== "POST") {
|
|
return res.status(405).json({ error: "Method not allowed" });
|
|
}
|
|
|
|
const { email, inviteCode } = req.body;
|
|
|
|
if (!email) {
|
|
return res.status(400).json({ error: "Email is required" });
|
|
}
|
|
|
|
try {
|
|
// Generate OTP
|
|
const otp = generateOTP();
|
|
const expiresAt = Date.now() + (5 * 60 * 1000); // 5 minutes expiry
|
|
|
|
// Store OTP in Firestore
|
|
await db.collection("otps").doc(email).set({
|
|
otp,
|
|
expiresAt,
|
|
attempts: 0,
|
|
inviteCode: inviteCode || null,
|
|
createdAt: Date.now(),
|
|
});
|
|
|
|
// In production, send actual email
|
|
console.log(`📧 OTP for ${email}: ${otp} (expires in 5 minutes)`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "Verification code sent to your email",
|
|
// Always include OTP in emulator mode for testing
|
|
otp,
|
|
});
|
|
} catch (error) {
|
|
console.error("Send OTP error:", error);
|
|
res.status(500).json({ error: "Failed to send verification code" });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Verify OTP Function
|
|
exports.verifyOTP = functions.https.onRequest((req, res) => {
|
|
cors(req, res, async () => {
|
|
if (req.method !== "POST") {
|
|
return res.status(405).json({ error: "Method not allowed" });
|
|
}
|
|
|
|
const { email, otp } = req.body;
|
|
|
|
if (!email || !otp) {
|
|
return res.status(400).json({ error: "Email and OTP are required" });
|
|
}
|
|
|
|
try {
|
|
// Retrieve OTP document
|
|
const otpDoc = await db.collection("otps").doc(email).get();
|
|
|
|
if (!otpDoc.exists) {
|
|
return res.status(400).json({ error: "Invalid verification code" });
|
|
}
|
|
|
|
const otpData = otpDoc.data();
|
|
|
|
// Check if OTP is expired
|
|
if (Date.now() > otpData.expiresAt) {
|
|
await otpDoc.ref.delete();
|
|
return res.status(400).json({ error: "Verification code has expired" });
|
|
}
|
|
|
|
// Check if too many attempts
|
|
if (otpData.attempts >= 5) {
|
|
await otpDoc.ref.delete();
|
|
return res.status(400).json({ error: "Too many failed attempts" });
|
|
}
|
|
|
|
// Verify OTP
|
|
if (otpData.otp !== otp) {
|
|
await otpDoc.ref.update({
|
|
attempts: (otpData.attempts || 0) + 1,
|
|
});
|
|
return res.status(400).json({ error: "Invalid verification code" });
|
|
}
|
|
|
|
// OTP is valid - clean up and create/find user
|
|
await otpDoc.ref.delete();
|
|
|
|
// Generate a unique user ID for this email if it doesn't exist
|
|
let userId;
|
|
let userDoc;
|
|
|
|
// Check if user already exists by email
|
|
const existingUserQuery = await db.collection("users")
|
|
.where("email", "==", email)
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (!existingUserQuery.empty) {
|
|
// User exists, get their ID
|
|
userDoc = existingUserQuery.docs[0];
|
|
userId = userDoc.id;
|
|
} else {
|
|
// Create new user
|
|
userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
userDoc = null;
|
|
}
|
|
|
|
// Prepare user object for response
|
|
const user = {
|
|
uid: userId,
|
|
email: email,
|
|
displayName: email.split("@")[0],
|
|
emailVerified: true,
|
|
};
|
|
|
|
// Create or update user document in Firestore
|
|
const userRef = db.collection("users").doc(userId);
|
|
|
|
const userData = {
|
|
id: userId,
|
|
email: email,
|
|
displayName: email.split("@")[0],
|
|
emailVerified: true,
|
|
lastLoginAt: Date.now(),
|
|
};
|
|
|
|
if (!userDoc) {
|
|
// Create new user document
|
|
userData.createdAt = Date.now();
|
|
await userRef.set(userData);
|
|
} else {
|
|
// Update existing user with latest login info
|
|
await userRef.update({
|
|
lastLoginAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
// Generate a simple session token (in production, use proper JWT)
|
|
const customToken = `session_${userId}_${Date.now()}`;
|
|
|
|
// Handle invitation if present
|
|
let inviteData = null;
|
|
if (otpData.inviteCode) {
|
|
try {
|
|
const inviteDoc = await db
|
|
.collectionGroup("invites")
|
|
.where("code", "==", otpData.inviteCode)
|
|
.where("status", "==", "pending")
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (!inviteDoc.empty) {
|
|
inviteData = inviteDoc.docs[0].data();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching invite:", error);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
user,
|
|
token: customToken,
|
|
invite: inviteData,
|
|
});
|
|
} catch (error) {
|
|
console.error("Verify OTP error:", error);
|
|
res.status(500).json({ error: "Failed to verify code" });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Create Invitation Function
|
|
exports.createInvitation = 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, email, role = "employee" } = req.body;
|
|
|
|
if (!orgId || !email) {
|
|
return res.status(400).json({ error: "Organization ID and email are required" });
|
|
}
|
|
|
|
try {
|
|
// Generate invite code
|
|
const code = Math.random().toString(36).substring(2, 15);
|
|
|
|
// Store invitation
|
|
const inviteRef = await db
|
|
.collection("orgs")
|
|
.doc(orgId)
|
|
.collection("invites")
|
|
.doc(code);
|
|
|
|
await inviteRef.set({
|
|
code,
|
|
email,
|
|
role,
|
|
orgId,
|
|
status: "pending",
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), // 7 days
|
|
});
|
|
|
|
// In production, send actual invitation email
|
|
console.log(`📧 Invitation sent to ${email} with code: ${code}`);
|
|
|
|
res.json({
|
|
success: true,
|
|
inviteCode: code,
|
|
message: "Invitation sent successfully",
|
|
});
|
|
} catch (error) {
|
|
console.error("Create invitation error:", error);
|
|
res.status(500).json({ error: "Failed to create invitation" });
|
|
}
|
|
});
|
|
|
|
// Get Invitation Status Function
|
|
exports.getInvitationStatus = functions.https.onRequest(async (req, res) => {
|
|
setCorsHeaders(res);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.status(204).send('');
|
|
return;
|
|
}
|
|
|
|
if (req.method !== "GET") {
|
|
return res.status(405).json({ error: "Method not allowed" });
|
|
}
|
|
|
|
const { code } = req.query;
|
|
|
|
if (!code) {
|
|
return res.status(400).json({ error: "Invitation code is required" });
|
|
}
|
|
|
|
try {
|
|
const inviteDoc = await db
|
|
.collectionGroup("invites")
|
|
.where("code", "==", code)
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (inviteDoc.empty) {
|
|
return res.status(404).json({ error: "Invitation not found" });
|
|
}
|
|
|
|
const invite = inviteDoc.docs[0].data();
|
|
|
|
// Check if expired
|
|
if (Date.now() > invite.expiresAt) {
|
|
return res.status(400).json({ error: "Invitation has expired" });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
invite,
|
|
});
|
|
} catch (error) {
|
|
console.error("Get invitation status error:", error);
|
|
res.status(500).json({ error: "Failed to get invitation status" });
|
|
}
|
|
});
|
|
|
|
// Consume Invitation Function
|
|
exports.consumeInvitation = 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 { code, userId } = req.body;
|
|
|
|
if (!code || !userId) {
|
|
return res.status(400).json({ error: "Invitation code and user ID are required" });
|
|
}
|
|
|
|
try {
|
|
const inviteSnapshot = await db
|
|
.collectionGroup("invites")
|
|
.where("code", "==", code)
|
|
.where("status", "==", "pending")
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (inviteSnapshot.empty) {
|
|
return res.status(404).json({ error: "Invitation not found or already used" });
|
|
}
|
|
|
|
const inviteDoc = inviteSnapshot.docs[0];
|
|
const invite = inviteDoc.data();
|
|
|
|
// Check if expired
|
|
if (Date.now() > invite.expiresAt) {
|
|
return res.status(400).json({ error: "Invitation has expired" });
|
|
}
|
|
|
|
// Mark invitation as consumed
|
|
await inviteDoc.ref.update({
|
|
status: "consumed",
|
|
consumedBy: userId,
|
|
consumedAt: Date.now(),
|
|
});
|
|
|
|
// Add user to organization employees
|
|
await db
|
|
.collection("orgs")
|
|
.doc(invite.orgId)
|
|
.collection("employees")
|
|
.doc(userId)
|
|
.set({
|
|
id: userId,
|
|
email: invite.email,
|
|
role: invite.role,
|
|
joinedAt: Date.now(),
|
|
status: "active",
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
orgId: invite.orgId,
|
|
message: "Invitation consumed successfully",
|
|
});
|
|
} catch (error) {
|
|
console.error("Consume invitation error:", error);
|
|
res.status(500).json({ error: "Failed to consume invitation" });
|
|
}
|
|
});
|
|
|
|
// Submit Employee Answers Function
|
|
exports.submitEmployeeAnswers = 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, employeeId, answers } = req.body;
|
|
|
|
if (!orgId || !employeeId || !answers) {
|
|
return res.status(400).json({ error: "Organization ID, employee ID, and answers are required" });
|
|
}
|
|
|
|
try {
|
|
// Store submission
|
|
const submissionRef = await db
|
|
.collection("orgs")
|
|
.doc(orgId)
|
|
.collection("submissions")
|
|
.doc(employeeId);
|
|
|
|
await submissionRef.set({
|
|
employeeId,
|
|
answers,
|
|
submittedAt: Date.now(),
|
|
status: "completed",
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: "Employee answers submitted successfully",
|
|
});
|
|
} catch (error) {
|
|
console.error("Submit employee answers error:", error);
|
|
res.status(500).json({ error: "Failed to submit answers" });
|
|
}
|
|
});
|
|
|
|
// Generate Employee Report Function
|
|
exports.generateEmployeeReport = 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 { employee, submission, companyWiki } = req.body;
|
|
|
|
if (!employee || !submission) {
|
|
return res.status(400).json({ error: "Employee and submission data are required" });
|
|
}
|
|
|
|
try {
|
|
let report;
|
|
|
|
if (openai) {
|
|
// Use OpenAI to generate the report
|
|
const prompt = `
|
|
You are an expert HR analyst. Generate a comprehensive employee performance report based on the following data:
|
|
|
|
Employee Information:
|
|
- Name: ${employee.name || employee.email}
|
|
- Role: ${employee.role || "Team Member"}
|
|
- Department: ${employee.department || "General"}
|
|
|
|
Employee Submission Data:
|
|
${JSON.stringify(submission, null, 2)}
|
|
|
|
Company Context:
|
|
${companyWiki ? JSON.stringify(companyWiki, null, 2) : "No company context provided"}
|
|
|
|
Generate a detailed report with the following structure:
|
|
- roleAndOutput: Current role assessment and performance rating
|
|
- behavioralInsights: Work style, communication, and team dynamics
|
|
- strengths: List of employee strengths
|
|
- weaknesses: Areas for improvement (mark critical issues)
|
|
- opportunities: Growth and development opportunities
|
|
- risks: Potential risks or concerns
|
|
- recommendations: Specific action items
|
|
- grading: Numerical scores for different performance areas
|
|
|
|
Return ONLY valid JSON that matches this structure. Be thorough but professional.
|
|
`.trim();
|
|
|
|
const completion = await openai.chat.completions.create({
|
|
model: "gpt-4o",
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content: "You are an expert HR analyst. Generate comprehensive employee performance reports in JSON format."
|
|
},
|
|
{
|
|
role: "user",
|
|
content: prompt
|
|
}
|
|
],
|
|
response_format: { type: "json_object" },
|
|
temperature: 0.7,
|
|
});
|
|
|
|
const aiResponse = completion.choices[0].message.content;
|
|
const parsedReport = JSON.parse(aiResponse);
|
|
|
|
report = {
|
|
employeeId: employee.id,
|
|
generatedAt: Date.now(),
|
|
summary: `AI-generated performance analysis for ${employee.name || employee.email}`,
|
|
...parsedReport
|
|
};
|
|
} else {
|
|
// Fallback to mock report when OpenAI is not available
|
|
report = {
|
|
employeeId: employee.id,
|
|
generatedAt: Date.now(),
|
|
summary: `Performance analysis for ${employee.name || employee.email}`,
|
|
roleAndOutput: {
|
|
currentRole: employee.role || "Team Member",
|
|
keyResponsibilities: ["Task completion", "Team collaboration", "Quality delivery"],
|
|
performanceRating: 85,
|
|
},
|
|
behavioralInsights: {
|
|
workStyle: "Collaborative and detail-oriented",
|
|
communicationSkills: "Strong verbal and written communication",
|
|
teamDynamics: "Positive team player",
|
|
},
|
|
strengths: [
|
|
"Excellent problem-solving abilities",
|
|
"Strong attention to detail",
|
|
"Reliable and consistent performance",
|
|
],
|
|
weaknesses: [
|
|
"Could improve time management",
|
|
"Needs to be more proactive in meetings",
|
|
],
|
|
opportunities: [
|
|
"Leadership development opportunities",
|
|
"Cross-functional project involvement",
|
|
"Skill enhancement in emerging technologies",
|
|
],
|
|
risks: [
|
|
"Potential burnout from heavy workload",
|
|
"Limited growth opportunities in current role",
|
|
],
|
|
recommendations: [
|
|
"Provide leadership training",
|
|
"Assign mentorship role",
|
|
"Consider promotion to senior position",
|
|
],
|
|
grading: {
|
|
overall: 85,
|
|
technical: 88,
|
|
communication: 82,
|
|
teamwork: 90,
|
|
leadership: 75,
|
|
},
|
|
};
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
report,
|
|
});
|
|
} catch (error) {
|
|
console.error("Generate employee report error:", error);
|
|
res.status(500).json({ error: "Failed to generate employee report" });
|
|
}
|
|
});
|
|
|
|
// Generate Company Wiki Function
|
|
exports.generateCompanyWiki = 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 { org, submissions = [] } = req.body;
|
|
|
|
if (!org) {
|
|
return res.status(400).json({ error: "Organization data is required" });
|
|
}
|
|
|
|
try {
|
|
let report, wiki;
|
|
|
|
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 completion = await openai.chat.completions.create({
|
|
model: "gpt-4o",
|
|
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,
|
|
});
|
|
|
|
const aiResponse = completion.choices[0].message.content;
|
|
const parsedResponse = JSON.parse(aiResponse);
|
|
|
|
report = {
|
|
generatedAt: Date.now(),
|
|
...parsedResponse.report
|
|
};
|
|
|
|
wiki = {
|
|
companyName: org.name,
|
|
generatedAt: Date.now(),
|
|
...parsedResponse.wiki
|
|
};
|
|
} else {
|
|
// Fallback to mock data when OpenAI is not available
|
|
report = {
|
|
generatedAt: Date.now(),
|
|
companyPerformance: {
|
|
overallScore: 82,
|
|
trend: "improving",
|
|
keyMetrics: {
|
|
productivity: 85,
|
|
satisfaction: 79,
|
|
retention: 88,
|
|
},
|
|
},
|
|
keyPersonnelChanges: [
|
|
{
|
|
type: "promotion",
|
|
employee: "John Doe",
|
|
details: "Promoted to Senior Developer",
|
|
impact: "positive",
|
|
},
|
|
],
|
|
immediateHiringNeeds: [
|
|
{
|
|
role: "Frontend Developer",
|
|
priority: "high",
|
|
timeline: "2-4 weeks",
|
|
skills: ["React", "TypeScript", "CSS"],
|
|
},
|
|
],
|
|
forwardOperatingPlan: {
|
|
nextQuarter: "Focus on product development and team expansion",
|
|
challenges: ["Scaling infrastructure", "Talent acquisition"],
|
|
opportunities: ["New market segments", "Technology partnerships"],
|
|
},
|
|
organizationalInsights: {
|
|
teamDynamics: "Strong collaboration across departments",
|
|
culturalHealth: "Positive and inclusive work environment",
|
|
communicationEffectiveness: "Good but could improve cross-team coordination",
|
|
},
|
|
strengths: [
|
|
"Strong technical expertise",
|
|
"Collaborative team culture",
|
|
"Innovative problem-solving approach",
|
|
],
|
|
gradingOverview: {
|
|
averagePerformance: 82,
|
|
topPerformers: 3,
|
|
needsImprovement: 1,
|
|
departmentBreakdown: {
|
|
engineering: 85,
|
|
design: 80,
|
|
product: 78,
|
|
},
|
|
},
|
|
};
|
|
|
|
wiki = {
|
|
companyName: org.name,
|
|
industry: org.industry,
|
|
description: org.description,
|
|
mission: org.mission || "To deliver excellent products and services",
|
|
values: org.values || ["Innovation", "Teamwork", "Excellence"],
|
|
culture: "Collaborative and growth-oriented",
|
|
generatedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
report,
|
|
wiki,
|
|
});
|
|
} catch (error) {
|
|
console.error("Generate company wiki error:", error);
|
|
res.status(500).json({ error: "Failed to generate company wiki" });
|
|
}
|
|
});
|
|
|
|
// Chat Function
|
|
exports.chat = 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 { message, employeeId, context } = req.body;
|
|
|
|
if (!message) {
|
|
return res.status(400).json({ error: "Message is required" });
|
|
}
|
|
|
|
try {
|
|
let response;
|
|
|
|
if (openai) {
|
|
// Use OpenAI for chat responses
|
|
const systemPrompt = `
|
|
You are an expert HR consultant and business analyst with access to employee performance data and company analytics.
|
|
You provide thoughtful, professional advice based on the employee context and company data provided.
|
|
|
|
${context ? `
|
|
Current Context:
|
|
${JSON.stringify(context, null, 2)}
|
|
` : ''}
|
|
|
|
Provide helpful, actionable insights while maintaining professional confidentiality and focusing on constructive feedback.
|
|
`.trim();
|
|
|
|
const completion = await openai.chat.completions.create({
|
|
model: "gpt-4o",
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content: systemPrompt
|
|
},
|
|
{
|
|
role: "user",
|
|
content: message
|
|
}
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 500,
|
|
});
|
|
|
|
response = completion.choices[0].message.content;
|
|
} else {
|
|
// Fallback responses when OpenAI is not available
|
|
const responses = [
|
|
"That's an interesting point about performance metrics. Based on the data, I'd recommend focusing on...",
|
|
"I can see from the employee report that there are opportunities for growth in...",
|
|
"The company analysis suggests that this area needs attention. Here's what I would suggest...",
|
|
"Based on the performance data, this employee shows strong potential in...",
|
|
];
|
|
|
|
response = responses[Math.floor(Math.random() * responses.length)];
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
response,
|
|
timestamp: Date.now(),
|
|
});
|
|
} catch (error) {
|
|
console.error("Chat error:", error);
|
|
res.status(500).json({ error: "Failed to process chat message" });
|
|
}
|
|
});
|
|
|
|
// Create Organization Function
|
|
exports.createOrganization = 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 { name, userId } = req.body;
|
|
|
|
if (!name || !userId) {
|
|
return res.status(400).json({ error: "Organization name and user ID are required" });
|
|
}
|
|
|
|
try {
|
|
// Generate unique organization ID
|
|
const orgId = `org_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
|
|
// Create comprehensive organization document
|
|
const orgData = {
|
|
name,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
onboardingCompleted: false,
|
|
ownerId: userId,
|
|
// Subscription fields (will be populated after Stripe setup)
|
|
subscription: {
|
|
status: 'trial', // trial, active, past_due, canceled
|
|
stripeCustomerId: null,
|
|
stripeSubscriptionId: null,
|
|
currentPeriodStart: null,
|
|
currentPeriodEnd: null,
|
|
trialEnd: Date.now() + (14 * 24 * 60 * 60 * 1000), // 14 day trial
|
|
},
|
|
// Usage tracking
|
|
usage: {
|
|
employeeCount: 0,
|
|
reportsGenerated: 0,
|
|
lastReportGeneration: null,
|
|
},
|
|
// Organization settings
|
|
settings: {
|
|
allowedEmployeeCount: 50, // Default limit
|
|
featuresEnabled: {
|
|
aiReports: true,
|
|
chat: true,
|
|
analytics: true,
|
|
}
|
|
}
|
|
};
|
|
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.set(orgData);
|
|
|
|
// Get user information from Firestore (since we don't use Firebase Auth)
|
|
const userRef = db.collection("users").doc(userId);
|
|
const userDoc = await userRef.get();
|
|
|
|
if (!userDoc.exists) {
|
|
console.error("User document not found:", userId);
|
|
return res.status(400).json({ error: "User not found" });
|
|
}
|
|
|
|
const userData = userDoc.data();
|
|
|
|
// Add user as owner to organization's employees collection
|
|
const employeeRef = orgRef.collection("employees").doc(userId);
|
|
await employeeRef.set({
|
|
id: userId,
|
|
role: "owner",
|
|
isOwner: true,
|
|
joinedAt: Date.now(),
|
|
status: "active",
|
|
name: userData.displayName || userData.email.split("@")[0],
|
|
email: userData.email,
|
|
department: "Management",
|
|
});
|
|
|
|
// Add organization to user's organizations (for multi-org support)
|
|
const userOrgRef = db.collection("users").doc(userId).collection("organizations").doc(orgId);
|
|
await userOrgRef.set({
|
|
orgId,
|
|
name,
|
|
role: "owner",
|
|
onboardingCompleted: false,
|
|
joinedAt: Date.now(),
|
|
});
|
|
|
|
// Update user document with latest activity
|
|
await userRef.update({
|
|
lastLoginAt: Date.now(),
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
orgId,
|
|
name,
|
|
role: "owner",
|
|
onboardingCompleted: false,
|
|
joinedAt: Date.now(),
|
|
subscription: orgData.subscription,
|
|
requiresSubscription: true, // Signal frontend to show subscription flow
|
|
});
|
|
} catch (error) {
|
|
console.error("Create organization error:", error);
|
|
res.status(500).json({ error: "Failed to create organization" });
|
|
}
|
|
});
|
|
|
|
// Get User Organizations Function
|
|
exports.getUserOrganizations = functions.https.onRequest(async (req, res) => {
|
|
setCorsHeaders(res);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.status(204).send('');
|
|
return;
|
|
}
|
|
|
|
if (req.method !== "GET") {
|
|
return res.status(405).json({ error: "Method not allowed" });
|
|
}
|
|
|
|
const userId = req.query.userId || req.params.userId;
|
|
|
|
if (!userId) {
|
|
return res.status(400).json({ error: "User ID is required" });
|
|
}
|
|
|
|
try {
|
|
// Get user's organizations
|
|
const userOrgsSnapshot = await db
|
|
.collection("users")
|
|
.doc(userId)
|
|
.collection("organizations")
|
|
.get();
|
|
|
|
const organizations = [];
|
|
userOrgsSnapshot.forEach(doc => {
|
|
organizations.push({
|
|
orgId: doc.id,
|
|
...doc.data(),
|
|
});
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
organizations,
|
|
});
|
|
} catch (error) {
|
|
console.error("Get user organizations error:", error);
|
|
res.status(500).json({ error: "Failed to get user organizations" });
|
|
}
|
|
});
|
|
|
|
// Join Organization Function (via invite)
|
|
exports.joinOrganization = 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 { userId, inviteCode } = req.body;
|
|
|
|
if (!userId || !inviteCode) {
|
|
return res.status(400).json({ error: "User ID and invite code are required" });
|
|
}
|
|
|
|
try {
|
|
// Find the invitation
|
|
const inviteSnapshot = await db
|
|
.collectionGroup("invites")
|
|
.where("code", "==", inviteCode)
|
|
.where("status", "==", "pending")
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (inviteSnapshot.empty) {
|
|
return res.status(404).json({ error: "Invitation not found or already used" });
|
|
}
|
|
|
|
const inviteDoc = inviteSnapshot.docs[0];
|
|
const invite = inviteDoc.data();
|
|
|
|
// Check if expired
|
|
if (Date.now() > invite.expiresAt) {
|
|
return res.status(400).json({ error: "Invitation has expired" });
|
|
}
|
|
|
|
const orgId = invite.orgId;
|
|
|
|
// Get organization details
|
|
const orgDoc = await db.collection("orgs").doc(orgId).get();
|
|
if (!orgDoc.exists()) {
|
|
return res.status(404).json({ error: "Organization not found" });
|
|
}
|
|
|
|
const orgData = orgDoc.data();
|
|
|
|
// Get user information from Firestore (since we don't use Firebase Auth)
|
|
const userRef = db.collection("users").doc(userId);
|
|
const userDoc = await userRef.get();
|
|
|
|
if (!userDoc.exists) {
|
|
console.error("User document not found:", userId);
|
|
return res.status(400).json({ error: "User not found" });
|
|
}
|
|
|
|
const userData = userDoc.data();
|
|
|
|
// Mark invitation as consumed
|
|
await inviteDoc.ref.update({
|
|
status: "consumed",
|
|
consumedBy: userId,
|
|
consumedAt: Date.now(),
|
|
});
|
|
|
|
// Add user to organization employees with full information
|
|
await db
|
|
.collection("orgs")
|
|
.doc(orgId)
|
|
.collection("employees")
|
|
.doc(userId)
|
|
.set({
|
|
id: userId,
|
|
email: userData.email,
|
|
name: userData.displayName || userData.email.split("@")[0],
|
|
role: invite.role || "employee",
|
|
joinedAt: Date.now(),
|
|
status: "active",
|
|
});
|
|
|
|
// Add organization to user's organizations
|
|
await db
|
|
.collection("users")
|
|
.doc(userId)
|
|
.collection("organizations")
|
|
.doc(orgId)
|
|
.set({
|
|
orgId,
|
|
name: orgData.name,
|
|
role: invite.role || "employee",
|
|
onboardingCompleted: orgData.onboardingCompleted || false,
|
|
joinedAt: Date.now(),
|
|
});
|
|
|
|
// Update user document with latest login activity
|
|
await userRef.update({
|
|
lastLoginAt: Date.now(),
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
orgId,
|
|
name: orgData.name,
|
|
role: invite.role || "employee",
|
|
onboardingCompleted: orgData.onboardingCompleted || false,
|
|
joinedAt: Date.now(),
|
|
});
|
|
} catch (error) {
|
|
console.error("Join organization error:", error);
|
|
res.status(500).json({ error: "Failed to join organization" });
|
|
}
|
|
});
|
|
|
|
// Create Stripe Checkout Session Function
|
|
exports.createCheckoutSession = 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, userId, userEmail, priceId } = req.body;
|
|
|
|
if (!orgId || !userId || !userEmail) {
|
|
return res.status(400).json({ error: "Organization ID, user ID, and email are required" });
|
|
}
|
|
|
|
if (!stripe) {
|
|
return res.status(500).json({ error: "Stripe not configured" });
|
|
}
|
|
|
|
try {
|
|
// Get or create Stripe customer
|
|
let customer;
|
|
const existingCustomers = await stripe.customers.list({
|
|
email: userEmail,
|
|
limit: 1,
|
|
});
|
|
|
|
if (existingCustomers.data.length > 0) {
|
|
customer = existingCustomers.data[0];
|
|
} else {
|
|
customer = await stripe.customers.create({
|
|
email: userEmail,
|
|
metadata: {
|
|
userId,
|
|
orgId,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Default to standard plan if no priceId provided
|
|
const defaultPriceId = priceId || process.env.STRIPE_PRICE_ID || 'price_standard_monthly';
|
|
|
|
// Create checkout session
|
|
const session = await stripe.checkout.sessions.create({
|
|
customer: customer.id,
|
|
payment_method_types: ['card'],
|
|
line_items: [
|
|
{
|
|
price: defaultPriceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
mode: 'subscription',
|
|
success_url: `${process.env.CLIENT_URL || 'http://localhost:5174'}/#/dashboard?session_id={CHECKOUT_SESSION_ID}`,
|
|
cancel_url: `${process.env.CLIENT_URL || 'http://localhost:5174'}/#/dashboard?canceled=true`,
|
|
metadata: {
|
|
orgId,
|
|
userId,
|
|
},
|
|
subscription_data: {
|
|
metadata: {
|
|
orgId,
|
|
userId,
|
|
},
|
|
trial_period_days: 14, // 14-day trial
|
|
},
|
|
});
|
|
|
|
// Update organization with customer ID
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.stripeCustomerId': customer.id,
|
|
'subscription.checkoutSessionId': session.id,
|
|
updatedAt: Date.now(),
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
sessionId: session.id,
|
|
sessionUrl: session.url,
|
|
customerId: customer.id,
|
|
});
|
|
} catch (error) {
|
|
console.error("Create checkout session error:", error);
|
|
res.status(500).json({ error: "Failed to create checkout session" });
|
|
}
|
|
});
|
|
|
|
// Handle Stripe Webhook Function
|
|
exports.stripeWebhook = functions.https.onRequest(async (req, res) => {
|
|
if (!stripe) {
|
|
return res.status(500).send('Stripe not configured');
|
|
}
|
|
|
|
const sig = req.headers['stripe-signature'];
|
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
|
|
let event;
|
|
|
|
try {
|
|
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
|
|
} catch (err) {
|
|
console.error('Webhook signature verification failed:', err.message);
|
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
}
|
|
|
|
// Handle the event
|
|
try {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed':
|
|
await handleCheckoutCompleted(event.data.object);
|
|
break;
|
|
case 'customer.subscription.created':
|
|
await handleSubscriptionCreated(event.data.object);
|
|
break;
|
|
case 'customer.subscription.updated':
|
|
await handleSubscriptionUpdated(event.data.object);
|
|
break;
|
|
case 'customer.subscription.deleted':
|
|
await handleSubscriptionDeleted(event.data.object);
|
|
break;
|
|
case 'invoice.payment_succeeded':
|
|
await handlePaymentSucceeded(event.data.object);
|
|
break;
|
|
case 'invoice.payment_failed':
|
|
await handlePaymentFailed(event.data.object);
|
|
break;
|
|
default:
|
|
console.log(`Unhandled event type ${event.type}`);
|
|
}
|
|
|
|
res.json({ received: true });
|
|
} catch (error) {
|
|
console.error('Webhook handler error:', error);
|
|
res.status(500).json({ error: 'Webhook handler failed' });
|
|
}
|
|
});
|
|
|
|
// Get Subscription Status Function
|
|
exports.getSubscriptionStatus = functions.https.onRequest(async (req, res) => {
|
|
setCorsHeaders(res);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.status(204).send('');
|
|
return;
|
|
}
|
|
|
|
if (req.method !== "GET") {
|
|
return res.status(405).json({ error: "Method not allowed" });
|
|
}
|
|
|
|
const { orgId } = req.query;
|
|
|
|
if (!orgId) {
|
|
return res.status(400).json({ error: "Organization ID is required" });
|
|
}
|
|
|
|
try {
|
|
const orgDoc = await db.collection("orgs").doc(orgId).get();
|
|
|
|
if (!orgDoc.exists()) {
|
|
return res.status(404).json({ error: "Organization not found" });
|
|
}
|
|
|
|
const orgData = orgDoc.data();
|
|
const subscription = orgData.subscription || {};
|
|
|
|
// If we have a Stripe subscription ID, get the latest status
|
|
if (stripe && subscription.stripeSubscriptionId) {
|
|
try {
|
|
const stripeSubscription = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
|
|
|
|
// Update local subscription data
|
|
await orgDoc.ref.update({
|
|
'subscription.status': stripeSubscription.status,
|
|
'subscription.currentPeriodStart': stripeSubscription.current_period_start * 1000,
|
|
'subscription.currentPeriodEnd': stripeSubscription.current_period_end * 1000,
|
|
updatedAt: Date.now(),
|
|
});
|
|
|
|
subscription.status = stripeSubscription.status;
|
|
subscription.currentPeriodStart = stripeSubscription.current_period_start * 1000;
|
|
subscription.currentPeriodEnd = stripeSubscription.current_period_end * 1000;
|
|
} catch (stripeError) {
|
|
console.error('Failed to fetch Stripe subscription:', stripeError);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
subscription,
|
|
orgId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Get subscription status error:", error);
|
|
res.status(500).json({ error: "Failed to get subscription status" });
|
|
}
|
|
});
|
|
|
|
// Webhook Helper Functions
|
|
async function handleCheckoutCompleted(session) {
|
|
const orgId = session.metadata?.orgId;
|
|
if (!orgId) return;
|
|
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.status': 'trialing',
|
|
'subscription.stripeSubscriptionId': session.subscription,
|
|
'subscription.checkoutSessionId': session.id,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
async function handleSubscriptionCreated(subscription) {
|
|
const orgId = subscription.metadata?.orgId;
|
|
if (!orgId) return;
|
|
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.status': subscription.status,
|
|
'subscription.stripeSubscriptionId': subscription.id,
|
|
'subscription.currentPeriodStart': subscription.current_period_start * 1000,
|
|
'subscription.currentPeriodEnd': subscription.current_period_end * 1000,
|
|
'subscription.trialEnd': subscription.trial_end ? subscription.trial_end * 1000 : null,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
async function handleSubscriptionUpdated(subscription) {
|
|
const orgId = subscription.metadata?.orgId;
|
|
if (!orgId) return;
|
|
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.status': subscription.status,
|
|
'subscription.currentPeriodStart': subscription.current_period_start * 1000,
|
|
'subscription.currentPeriodEnd': subscription.current_period_end * 1000,
|
|
'subscription.trialEnd': subscription.trial_end ? subscription.trial_end * 1000 : null,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
async function handleSubscriptionDeleted(subscription) {
|
|
const orgId = subscription.metadata?.orgId;
|
|
if (!orgId) return;
|
|
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.status': 'canceled',
|
|
'subscription.currentPeriodEnd': subscription.current_period_end * 1000,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
async function handlePaymentSucceeded(invoice) {
|
|
const subscriptionId = invoice.subscription;
|
|
if (!subscriptionId) return;
|
|
|
|
// Update subscription status to active
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
const orgId = subscription.metadata?.orgId;
|
|
|
|
if (orgId) {
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.status': 'active',
|
|
'subscription.currentPeriodStart': subscription.current_period_start * 1000,
|
|
'subscription.currentPeriodEnd': subscription.current_period_end * 1000,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handlePaymentFailed(invoice) {
|
|
const subscriptionId = invoice.subscription;
|
|
if (!subscriptionId) return;
|
|
|
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
const orgId = subscription.metadata?.orgId;
|
|
|
|
if (orgId) {
|
|
const orgRef = db.collection("orgs").doc(orgId);
|
|
await orgRef.update({
|
|
'subscription.status': 'past_due',
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// exports.helloWorld = onRequest((request, response) => {
|
|
// response.send("Hello from Firebase!");
|
|
// });
|
|
|
|
// exports.sendOTP = onRequest(async (request, response) => {
|
|
// // Set CORS headers
|
|
// response.set('Access-Control-Allow-Origin', '*');
|
|
// response.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
// response.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
// if (request.method === 'OPTIONS') {
|
|
// response.status(204).send('');
|
|
// return;
|
|
// }
|
|
|
|
// if (request.method !== 'POST') {
|
|
// response.status(405).json({ error: 'Method not allowed' });
|
|
// return;
|
|
// }
|
|
|
|
// const { email } = request.body;
|
|
|
|
// if (!email) {
|
|
// response.status(400).json({ error: 'Email is required' });
|
|
// return;
|
|
// }
|
|
|
|
// // Generate a simple OTP
|
|
// const otp = Math.floor(100000 + Math.random() * 900000).toString();
|
|
|
|
// response.json({
|
|
// success: true,
|
|
// message: 'Verification code sent to your email',
|
|
// otp: otp // Always return OTP in emulator mode
|
|
// });
|
|
// });
|
|
|
|
// exports.verifyOTP = onRequest(async (request, response) => {
|
|
// // Set CORS headers
|
|
// response.set('Access-Control-Allow-Origin', '*');
|
|
// response.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
// response.set('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
// if (request.method === 'OPTIONS') {
|
|
// response.status(204).send('');
|
|
// return;
|
|
// }
|
|
|
|
// if (request.method !== 'POST') {
|
|
// response.status(405).json({ error: 'Method not allowed' });
|
|
// return;
|
|
// }
|
|
|
|
// const { email, otp } = request.body;
|
|
|
|
// if (!email || !otp) {
|
|
// response.status(400).json({ error: 'Email and OTP are required' });
|
|
// return;
|
|
// }
|
|
|
|
// // Mock verification - accept any 6-digit code
|
|
// if (otp.length === 6) {
|
|
// response.json({
|
|
// success: true,
|
|
// user: {
|
|
// uid: 'demo-user-123',
|
|
// email: email,
|
|
// displayName: email.split('@')[0],
|
|
// emailVerified: true
|
|
// },
|
|
// token: 'demo-token-123'
|
|
// });
|
|
// } else {
|
|
// response.status(400).json({ error: 'Invalid verification code' });
|
|
// }
|
|
// });
|