2893 lines
97 KiB
JavaScript
2893 lines
97 KiB
JavaScript
const { onRequest } = require("firebase-functions/v2/https");
|
||
const admin = require("firebase-admin");
|
||
const OpenAI = require("openai");
|
||
const Stripe = require("stripe");
|
||
|
||
const serviceAccount = require("./auditly-c0027-firebase-adminsdk-fbsvc-1db7c58141.json");
|
||
|
||
admin.initializeApp({
|
||
credential: admin.credential.cert(serviceAccount)
|
||
});
|
||
const db = admin.firestore();
|
||
|
||
// Auth middleware function to validate tokens and extract user context
|
||
const validateAuthAndGetContext = async (req) => {
|
||
const authHeader = req.headers.authorization;
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
throw new Error('Missing or invalid authorization header');
|
||
}
|
||
|
||
const token = authHeader.substring(7);
|
||
|
||
// Validate token format (should start with 'session_')
|
||
if (!token.startsWith('session_')) {
|
||
throw new Error('Invalid token format');
|
||
}
|
||
|
||
// Look up token in Firestore
|
||
const tokenDoc = await db.collection("authTokens").doc(token).get();
|
||
|
||
if (!tokenDoc.exists) {
|
||
throw new Error('Token not found');
|
||
}
|
||
|
||
const tokenData = tokenDoc.data();
|
||
|
||
if (!tokenData.isActive) {
|
||
throw new Error('Token is inactive');
|
||
}
|
||
|
||
if (Date.now() > tokenData.expiresAt) {
|
||
throw new Error('Token has expired');
|
||
}
|
||
|
||
// Update last used timestamp
|
||
await tokenDoc.ref.update({ lastUsedAt: Date.now() });
|
||
|
||
// Get user's organizations
|
||
const userOrgsSnapshot = await db.collection("users").doc(tokenData.userId).collection("organizations").get();
|
||
const orgIds = userOrgsSnapshot.docs.map(doc => doc.id);
|
||
|
||
return {
|
||
userId: tokenData.userId,
|
||
orgIds: orgIds,
|
||
// For backward compatibility, use first org as default
|
||
orgId: orgIds[0] || null,
|
||
token: token
|
||
};
|
||
};
|
||
|
||
// Helper function to verify user has access to specific organization
|
||
const verifyOrgAccess = (authContext, targetOrgId) => {
|
||
if (!targetOrgId) {
|
||
return authContext.orgId; // Use default org
|
||
}
|
||
|
||
if (!authContext.orgIds.includes(targetOrgId)) {
|
||
throw new Error('Unauthorized access to organization');
|
||
}
|
||
|
||
return targetOrgId;
|
||
};
|
||
|
||
// 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;
|
||
|
||
const RESPONSE_FORMAT_EMPLOYEE = {
|
||
"type": "object",
|
||
"properties": {
|
||
"employeeId": {
|
||
"type": "string"
|
||
},
|
||
"department": {
|
||
"type": "string"
|
||
},
|
||
"role": {
|
||
"type": "string"
|
||
},
|
||
"roleAndOutput": {
|
||
"type": "object",
|
||
"properties": {
|
||
"responsibilities": {
|
||
"type": "string",
|
||
"examples": [
|
||
"Recruiting influencers, onboarding, campaign support, business development."
|
||
]
|
||
},
|
||
"clarityOnRole": {
|
||
"type": "string",
|
||
"examples": [
|
||
"10/10 – Feels very clear on responsibilities."
|
||
]
|
||
},
|
||
"selfRatedOutput": {
|
||
"type": "string",
|
||
"examples": [
|
||
"7/10 – Indicates decent performance but room to grow."
|
||
]
|
||
},
|
||
"recurringTasks": {
|
||
"type": "string",
|
||
"examples": [
|
||
"Influencer outreach, onboarding, communications."
|
||
]
|
||
}
|
||
}
|
||
},
|
||
"insights": {
|
||
"type": "object",
|
||
"properties": {
|
||
"personalityInsights": {
|
||
"type": "string",
|
||
"examples": [
|
||
"Loyal, well-liked by influencers, eager to grow, client-facing interest."
|
||
]
|
||
},
|
||
"psychologicalIndicators": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "string"
|
||
},
|
||
"examples": [
|
||
[
|
||
"Scores high on optimism and external motivation.",
|
||
"Shows ambition but lacks self-discipline in execution.",
|
||
"Displays a desire for recognition and community; seeks more appreciation."
|
||
]
|
||
]
|
||
},
|
||
"selfAwareness": {
|
||
"type": "string",
|
||
"examples": [
|
||
"High – acknowledges weaknesses like lateness and disorganization."
|
||
]
|
||
},
|
||
"emotionalResponses": {
|
||
"type": "string",
|
||
"examples": [
|
||
"Frustrated by campaign disorganization; would prefer closer collaboration."
|
||
]
|
||
},
|
||
"growthDesire": {
|
||
"type": "string",
|
||
"examples": [
|
||
"Interested in becoming more client-facing and shifting toward biz dev."
|
||
]
|
||
}
|
||
}
|
||
},
|
||
"strengths": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "string"
|
||
},
|
||
"examples": [
|
||
[
|
||
"Builds strong relationships with influencers.",
|
||
"Has sales and outreach potential.",
|
||
"Loyal, driven, and values-aligned with the company mission.",
|
||
"Open to feedback and self-improvement."
|
||
]
|
||
]
|
||
},
|
||
"weaknessess": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "string"
|
||
},
|
||
"examples": [
|
||
"Critical Issue: Disorganized and late with deliverables — confirmed by previous internal notes.",
|
||
"Poor implementation and recruiting output — does not effectively close the loop on influencer onboarding.",
|
||
"May unintentionally cause friction with campaigns team by stepping outside process boundaries."
|
||
]
|
||
},
|
||
"opportunities": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {
|
||
"type": "string"
|
||
},
|
||
"description": {
|
||
"type": "string"
|
||
}
|
||
}
|
||
},
|
||
"examples": [
|
||
[
|
||
{
|
||
"title": "Role Adjustment",
|
||
"description": "Shift fully to Influencer Manager & Biz Dev Outreach as planned. Remove all execution and recruitment responsibilities."
|
||
},
|
||
{
|
||
"title": "Accountability Support",
|
||
"description": "Pair with a high-output implementer (new hire) to balance Gentry’s strategic skills."
|
||
}
|
||
]
|
||
]
|
||
},
|
||
"risks": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "string"
|
||
},
|
||
"examples": [
|
||
[
|
||
"Without strict structure, Gentry’s performance will stay flat or become a bottleneck.",
|
||
"If kept in a dual-role (recruiting + outreach), productivity will suffer.",
|
||
"He needs system constraints and direct oversight to stay focused."
|
||
]
|
||
]
|
||
},
|
||
"recommendations": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "string"
|
||
},
|
||
"examples": [
|
||
[
|
||
"Keep. But immediately restructure his role: Remove recruiting and logistical tasks. Focus only on influencer relationship-building, pitching, and business development.",
|
||
"Pair him with a new hire who is ultra-organized and can execute on Gentry’s deals."
|
||
]
|
||
]
|
||
},
|
||
"gradingOverview": {
|
||
"grade": { "type": "string" },
|
||
"reliability": { "type": "number" },
|
||
"roleFit": { "type": "number" },
|
||
"scalability": { "type": "number" },
|
||
"output": { "type": "number" },
|
||
"initiative": { "type": "number" }
|
||
}
|
||
}
|
||
}
|
||
|
||
RESPONSE_FORMAT_COMPANY = {
|
||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||
"title": "CompanyReport",
|
||
"type": "object",
|
||
"properties": {
|
||
"id": { "type": "string" },
|
||
"createdAt": { "type": "number" },
|
||
"overview": {
|
||
"type": "object",
|
||
"properties": {
|
||
"totalEmployees": { "type": "number" },
|
||
"departmentBreakdown": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"department": { "type": "string" },
|
||
"count": { "type": "number" }
|
||
},
|
||
"required": ["department", "count"]
|
||
}
|
||
},
|
||
"submissionRate": { "type": "number" },
|
||
"lastUpdated": { "type": "number" },
|
||
"averagePerformanceScore": { "type": "number" },
|
||
"riskLevel": {
|
||
"type": "string",
|
||
"enum": ["Low", "Medium", "High"]
|
||
}
|
||
},
|
||
"required": ["totalEmployees", "departmentBreakdown", "submissionRate", "lastUpdated"]
|
||
},
|
||
"weaknesses": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": { "type": "string" },
|
||
"description": { "type": "string" }
|
||
},
|
||
"required": ["title", "description"]
|
||
}
|
||
},
|
||
"personnelChanges": {
|
||
"type": "object",
|
||
"properties": {
|
||
"newHires": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": { "type": "string" },
|
||
"department": { "type": "string" },
|
||
"role": { "type": "string" },
|
||
"impact": { "type": "string" }
|
||
},
|
||
"required": ["name", "department", "role"]
|
||
}
|
||
},
|
||
"promotions": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": { "type": "string" },
|
||
"fromRole": { "type": "string" },
|
||
"toRole": { "type": "string" },
|
||
"impact": { "type": "string" }
|
||
},
|
||
"required": ["name", "fromRole", "toRole"]
|
||
}
|
||
},
|
||
"departures": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": { "type": "string" },
|
||
"department": { "type": "string" },
|
||
"reason": { "type": "string" },
|
||
"impact": { "type": "string" }
|
||
},
|
||
"required": ["name", "department", "reason"]
|
||
}
|
||
}
|
||
},
|
||
"required": ["newHires", "promotions", "departures"]
|
||
},
|
||
"immediateHiringNeeds": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"department": { "type": "string" },
|
||
"role": { "type": "string" },
|
||
"priority": {
|
||
"type": "string",
|
||
"enum": ["High", "Medium", "Low"]
|
||
},
|
||
"reasoning": { "type": "string" },
|
||
"urgency": {
|
||
"type": "string",
|
||
"enum": ["high", "medium", "low"]
|
||
}
|
||
},
|
||
"required": ["department", "role", "priority", "reasoning"]
|
||
}
|
||
},
|
||
"forwardOperatingPlan": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": { "type": "string" },
|
||
"details": {
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
}
|
||
},
|
||
"required": ["title", "details"]
|
||
}
|
||
},
|
||
"strengths": {
|
||
"type": "array",
|
||
"items": { "type": "string" }
|
||
},
|
||
"organizationalImpactSummary": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"category": {
|
||
"type": "string",
|
||
"enum": [
|
||
"Mission Critical",
|
||
"Highly Valuable",
|
||
"Core Support",
|
||
"Low Criticality"
|
||
]
|
||
},
|
||
"employees": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"employeeName": { "type": "string" },
|
||
"impact": { "type": "string" },
|
||
"description": { "type": "string" },
|
||
"suggestedPay": { "type": "string", "description": "Suggested yearly wage for the employee", "example": "$70,000" }
|
||
},
|
||
"required": ["employeeName", "impact", "description", "suggestedPay"]
|
||
}
|
||
}
|
||
},
|
||
"required": ["category", "employees"]
|
||
}
|
||
},
|
||
"gradingBreakdown": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"departmentNameShort": { "type": "string" },
|
||
"departmentName": { "type": "string" },
|
||
"lead": { "type": "string" },
|
||
"support": { "type": "string" },
|
||
"departmentGrade": { "type": "string" },
|
||
"executiveSummary": { "type": "string" },
|
||
"teamScores": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"employeeName": { "type": "string" },
|
||
"grade": { "type": "string" },
|
||
"reliability": { "type": "number" },
|
||
"roleFit": { "type": "number" },
|
||
"scalability": { "type": "number" },
|
||
"output": { "type": "number" },
|
||
"initiative": { "type": "number" }
|
||
},
|
||
"required": [
|
||
"employeeName",
|
||
"grade",
|
||
"reliability",
|
||
"roleFit",
|
||
"scalability",
|
||
"output",
|
||
"initiative"
|
||
]
|
||
}
|
||
}
|
||
},
|
||
"required": [
|
||
"departmentNameShort",
|
||
"departmentName",
|
||
"lead",
|
||
"support",
|
||
"departmentGrade",
|
||
"executiveSummary",
|
||
"teamScores"
|
||
]
|
||
}
|
||
},
|
||
"executiveSummary": { "type": "string" }
|
||
},
|
||
"required": [
|
||
"id",
|
||
"createdAt",
|
||
"overview",
|
||
"weaknesses",
|
||
"personnelChanges",
|
||
"immediateHiringNeeds",
|
||
"strengths",
|
||
"gradingBreakdown",
|
||
"executiveSummary"
|
||
]
|
||
}
|
||
|
||
|
||
// Helper function to generate OTP
|
||
const generateOTP = () => {
|
||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||
};
|
||
|
||
|
||
// Send OTP Function
|
||
exports.sendOTP = onRequest({ cors: true }, async (req, res) => {
|
||
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 = onRequest({ cors: true }, async (req, res) => {
|
||
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()}`;
|
||
|
||
// Store auth token in Firestore for validation
|
||
await db.collection("authTokens").doc(customToken).set({
|
||
userId: userId,
|
||
createdAt: Date.now(),
|
||
expiresAt: Date.now() + (30 * 24 * 60 * 60 * 1000), // 30 days
|
||
lastUsedAt: Date.now(),
|
||
isActive: true
|
||
});
|
||
|
||
// 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 = onRequest({ cors: true }, async (req, res) => {
|
||
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
const { name, email, role = "employee", department } = req.body;
|
||
|
||
if (!email || !name) {
|
||
return res.status(400).json({ error: "Name and email are required" });
|
||
}
|
||
|
||
// Use the user's default organization (first one)
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
// 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)}`;
|
||
|
||
// 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)
|
||
.collection("invites")
|
||
.doc(code);
|
||
|
||
await inviteRef.set({
|
||
code,
|
||
employee,
|
||
email,
|
||
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 || 'https://auditly-one.vercel.app';
|
||
const inviteLink = `${baseUrl}/#/employee-form/${code}`;
|
||
try {
|
||
if (!!process.env.SENDGRID_API_KEY) {
|
||
await fetch('https://api.sendgrid.com/v3/mail/send', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
|
||
},
|
||
body: JSON.stringify({
|
||
personalizations: [
|
||
{
|
||
to: [{ email }],
|
||
dynamic_template_data: {
|
||
name,
|
||
inviteLink,
|
||
},
|
||
},
|
||
],
|
||
from: { email: 'no-reply@auditly.com', name: 'Auditly' },
|
||
template_id: process.env.SENDGRID_TEMPLATE_ID,
|
||
}),
|
||
});
|
||
console.log(`📧 Invitation sent to ${email} (${name}) with code: ${code}`);
|
||
}
|
||
} catch (error) {
|
||
console.error("SendGrid email error:", error);
|
||
}
|
||
|
||
// In production, send actual invitation email
|
||
console.log(`📧 Invite link: ${inviteLink}`);
|
||
|
||
res.json({
|
||
success: true,
|
||
code,
|
||
employee,
|
||
inviteLink,
|
||
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 = onRequest({ cors: true }, async (req, 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,
|
||
used: invite.status !== 'pending',
|
||
employee: invite.employee,
|
||
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 = onRequest({ cors: true }, async (req, 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) {
|
||
return res.status(400).json({ error: "Invitation code is 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" });
|
||
}
|
||
|
||
// 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: employee.id,
|
||
consumedAt: Date.now(),
|
||
});
|
||
|
||
// Add employee to organization using data from invite
|
||
await db
|
||
.collection("orgs")
|
||
.doc(invite.orgId)
|
||
.collection("employees")
|
||
.doc(employee.id)
|
||
.set({
|
||
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({
|
||
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 = onRequest({ cors: true }, async (req, res) => {
|
||
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
const { employeeId, answers, inviteCode } = req.body;
|
||
|
||
try {
|
||
let finalOrgId, finalEmployeeId;
|
||
|
||
if (inviteCode) {
|
||
// Invite-based submission (no auth required)
|
||
if (!inviteCode || !answers) {
|
||
return res.status(400).json({ error: "Invite code and answers are required for invite submissions" });
|
||
}
|
||
|
||
// Look up the invite to get employee and org data (should be pending, not consumed yet)
|
||
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" });
|
||
}
|
||
|
||
finalOrgId = invite.orgId;
|
||
finalEmployeeId = invite.employee.id;
|
||
|
||
// Consume the invitation now
|
||
await inviteDoc.ref.update({
|
||
status: "consumed",
|
||
consumedBy: finalEmployeeId,
|
||
consumedAt: Date.now(),
|
||
});
|
||
|
||
// Add employee to organization if not already added
|
||
const employeeRef = db
|
||
.collection("orgs")
|
||
.doc(finalOrgId)
|
||
.collection("employees")
|
||
.doc(finalEmployeeId);
|
||
|
||
const employeeDoc = await employeeRef.get();
|
||
if (!employeeDoc.exists) {
|
||
await employeeRef.set({
|
||
id: invite.employee.id,
|
||
name: invite.employee.name || invite.employee.email.split("@")[0],
|
||
email: invite.employee.email,
|
||
role: invite.employee.role || "employee",
|
||
department: invite.employee.department || "General",
|
||
joinedAt: Date.now(),
|
||
status: "active",
|
||
inviteCode: inviteCode,
|
||
});
|
||
}
|
||
} else {
|
||
// Authenticated submission
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
if (!employeeId || !answers) {
|
||
return res.status(400).json({ error: "Employee ID and answers are required for authenticated submissions" });
|
||
}
|
||
|
||
finalOrgId = authContext.orgId;
|
||
finalEmployeeId = employeeId;
|
||
|
||
if (!finalOrgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
}
|
||
|
||
// Store submission
|
||
const submissionRef = await db
|
||
.collection("orgs")
|
||
.doc(finalOrgId)
|
||
.collection("submissions")
|
||
.doc(finalEmployeeId);
|
||
|
||
await submissionRef.set({
|
||
employeeId: finalEmployeeId,
|
||
answers,
|
||
submittedAt: Date.now(),
|
||
status: "completed",
|
||
submissionType: inviteCode ? "invite" : "regular",
|
||
...(inviteCode && { inviteCode })
|
||
});
|
||
|
||
// Generate employee report automatically with company context
|
||
try {
|
||
// Get employee data
|
||
const employeeDoc = await db
|
||
.collection("orgs")
|
||
.doc(finalOrgId)
|
||
.collection("employees")
|
||
.doc(finalEmployeeId)
|
||
.get();
|
||
|
||
const employeeData = employeeDoc.exists ? employeeDoc.data() : null;
|
||
|
||
// Get company onboarding data for LLM context
|
||
const orgDoc = await db.collection("orgs").doc(finalOrgId).get();
|
||
const orgData = orgDoc.exists ? orgDoc.data() : {};
|
||
|
||
// Prepare company context (onboarding data)
|
||
let companyContext = {
|
||
name: orgData.name,
|
||
};
|
||
if (orgData.onboardingData) {
|
||
companyContext = {
|
||
...companyContext,
|
||
...orgData.onboardingData
|
||
};
|
||
}
|
||
// Prepare submission data
|
||
const submissionData = {
|
||
employeeId: finalEmployeeId,
|
||
answers,
|
||
submittedAt: Date.now(),
|
||
status: "completed",
|
||
companyContext,
|
||
};
|
||
|
||
// Generate the report using the existing function logic
|
||
let report;
|
||
if (openai) {
|
||
// Use OpenAI to generate the report with company context
|
||
const prompt = `
|
||
You are a cut-and-dry expert business analyst. Return ONLY JSON that conforms to the provided schema:
|
||
|
||
Employee Information:
|
||
- Name: ${employeeData?.name || employeeData?.email || 'Unknown'}
|
||
- Role: ${employeeData?.role || "Team Member"}
|
||
- Department: ${employeeData?.department || "General"}
|
||
- Email: ${employeeData?.email || 'Unknown'}
|
||
|
||
Employee Questionnaire Responses:
|
||
${JSON.stringify(answers, null, 2)}
|
||
|
||
Company Context & Alignment Criteria:
|
||
${JSON.stringify(companyContext, null, 2)}
|
||
|
||
Generate a detailed report that:
|
||
1. Evaluates how well the employee aligns with company values and culture
|
||
2. Assesses their role performance and output
|
||
3. Identifies behavioral insights and work patterns
|
||
4. Highlights strengths and areas for improvement
|
||
5. Provides specific recommendations for growth
|
||
6. Suggests opportunities that align with company goals
|
||
7. Identifies any risks or concerns
|
||
8. Provides numerical grading across key performance areas
|
||
|
||
Return ONLY valid JSON that matches this structure:
|
||
${JSON.stringify(RESPONSE_FORMAT_EMPLOYEE, null, 2)}
|
||
|
||
Be thorough, professional, and focus on actionable insights.
|
||
`.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 that evaluate company alignment and performance."
|
||
},
|
||
{
|
||
role: "user",
|
||
content: prompt
|
||
}
|
||
],
|
||
response_format: { type: "json_object" },
|
||
temperature: 0.7,
|
||
});
|
||
|
||
const aiResponse = completion.choices[0].message.content;
|
||
const parsedReport = JSON.parse(aiResponse);
|
||
|
||
console.log(parsedReport);
|
||
|
||
report = {
|
||
employeeId: finalEmployeeId,
|
||
employeeName: employeeData?.name || employeeData?.email || 'Employee',
|
||
role: employeeData?.role || "Team Member",
|
||
email: employeeData?.email || 'Unknown',
|
||
generatedAt: Date.now(),
|
||
summary: `AI-generated performance analysis for ${employeeData?.name || employeeData?.email || 'Employee'}`,
|
||
submissionId: finalEmployeeId,
|
||
companyContext: companyContext,
|
||
...parsedReport
|
||
};
|
||
} else {
|
||
// Fallback to mock report when OpenAI is not available
|
||
report = {
|
||
employeeId: finalEmployeeId,
|
||
generatedAt: Date.now(),
|
||
summary: `Performance analysis for ${employeeData?.name || employeeData?.email || 'Employee'}`,
|
||
submissionId: finalEmployeeId,
|
||
companyContext: companyContext,
|
||
roleAndOutput: {
|
||
currentRole: employeeData?.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",
|
||
],
|
||
companyAlignment: {
|
||
valuesAlignment: 88,
|
||
cultureAlignment: 82,
|
||
missionAlignment: 85
|
||
},
|
||
grading: {
|
||
overall: 85,
|
||
technical: 88,
|
||
communication: 82,
|
||
teamwork: 90,
|
||
leadership: 75,
|
||
},
|
||
};
|
||
}
|
||
|
||
// Store the report in Firestore
|
||
const reportRef = db
|
||
.collection("orgs")
|
||
.doc(finalOrgId)
|
||
.collection("reports")
|
||
.doc(finalEmployeeId);
|
||
|
||
await reportRef.set(report);
|
||
|
||
console.log(`Employee report generated and stored for ${finalEmployeeId} in org ${finalOrgId}`);
|
||
|
||
} catch (reportError) {
|
||
console.error("Failed to generate employee report:", reportError);
|
||
// Don't fail the submission if report generation fails
|
||
}
|
||
|
||
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 = onRequest({ cors: true }, async (req, 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 a cut-and-dry expert business analyst. Return ONLY JSON that conforms to the provided schema:
|
||
|
||
Employee Information:
|
||
- Name: ${employee?.name || employee?.email || 'Unknown'}
|
||
- Role: ${employee?.role || "Team Member"}
|
||
- Department: ${employee?.department || "General"}
|
||
- Email: ${employee?.email || 'Unknown'}
|
||
|
||
Employee Submission Data:
|
||
${JSON.stringify(submission, null, 2)}
|
||
|
||
Company Context:
|
||
${companyWiki ? JSON.stringify(companyWiki, null, 2) : "No company context provided"}
|
||
|
||
Generate a detailed report that:
|
||
1. Evaluates how well the employee aligns with company values and culture
|
||
2. Assesses their role performance and output
|
||
3. Identifies behavioral insights and work patterns
|
||
4. Highlights strengths and areas for improvement
|
||
5. Provides specific recommendations for growth
|
||
6. Suggests opportunities that align with company goals
|
||
7. Identifies any risks or concerns
|
||
8. Provides numerical grading across key performance areas
|
||
|
||
Return ONLY valid JSON that matches this structure:
|
||
${JSON.stringify(RESPONSE_FORMAT_EMPLOYEE, null, 2)}
|
||
|
||
Be thorough, professional, and focus on actionable insights.
|
||
`.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 = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
const { org, submissions = [] } = req.body;
|
||
|
||
if (!org) {
|
||
return res.status(400).json({ error: "Organization data is required" });
|
||
}
|
||
|
||
const orgData = {
|
||
id: org.id,
|
||
name: org.name,
|
||
contextualData: org.onboardingData,
|
||
metrics: org.metrics
|
||
}
|
||
|
||
try {
|
||
let report, wiki;
|
||
|
||
if (openai) {
|
||
// Use OpenAI to generate the company report
|
||
|
||
const user = `You are a cut-and-dry expert business analyst who shys to no truths and with get a business in tip-top shape within swiftness. Return ONLY JSON that conforms to the provided schema:
|
||
|
||
Employee Submissions:
|
||
${JSON.stringify(submissions, null, 2)}
|
||
|
||
Company Context:
|
||
${JSON.stringify(orgData, null, 2)}
|
||
|
||
Generate a detailed report that:
|
||
1. Evaluates the company based on all the key sections in the JSON schema, being thorough to touch on all categories and employees
|
||
2. Attempts to at your best effort further the companies success and growth potential
|
||
3. Provides clear, concise, and actionable recommendations for improvement
|
||
4. Doesn't cater to sugarcoating or vague generalities
|
||
5. Will beat the nail into the coffin of inefficiency with precise solutions, getting rid of all weak points.
|
||
|
||
Return ONLY valid JSON that matches this JSON SCHEMA:
|
||
${JSON.stringify(RESPONSE_FORMAT_COMPANY, null, 0)}
|
||
|
||
Be thorough, professional, and focus on actionable insights.
|
||
`;
|
||
|
||
const completion = await openai.chat.completions.create({
|
||
model: "gpt-4o",
|
||
response_format: { type: "json_object" },
|
||
messages: [
|
||
{ role: "user", content: user }
|
||
]
|
||
});
|
||
|
||
// content is guaranteed to be schema-conformant JSON
|
||
const parsed = JSON.parse(completion.choices[0].message.content);
|
||
|
||
report = {
|
||
generatedAt: Date.now(),
|
||
...parsed
|
||
};
|
||
|
||
const reportRef = db
|
||
.collection("orgs")
|
||
.doc(orgId)
|
||
.collection("companyReport")
|
||
.doc("main");
|
||
|
||
await reportRef.set(report);
|
||
|
||
console.log(report);
|
||
return res.status(200).json({
|
||
success: true,
|
||
report
|
||
});
|
||
|
||
} 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,
|
||
},
|
||
},
|
||
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",
|
||
]
|
||
};
|
||
|
||
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(),
|
||
};
|
||
return res.status(200).json({
|
||
success: true,
|
||
...report
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("Generate company wiki error:", error);
|
||
res.status(500).json({ error: "Failed to generate company wiki" });
|
||
}
|
||
});
|
||
|
||
// Chat Function
|
||
exports.chat = onRequest({ cors: true }, async (req, 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, mentions, attachments } = 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 a cut-and-dry expert business analyst.
|
||
You provide thoughtful, professional advice based on the employee context and company data provided.
|
||
|
||
${context ? `
|
||
Current Context:
|
||
${JSON.stringify(context, null, 2)}
|
||
` : ''}
|
||
|
||
${mentions && mentions.length > 0 ? `
|
||
Mentioned Employees:
|
||
${mentions.map(emp => `- ${emp.name} (${emp.role || 'Employee'})`).join('\n')}
|
||
` : ''}
|
||
|
||
You will discuss employees with the employer to help:
|
||
1. Evaluate the company based on all provided data, being thorough to touch on all information gathered from said employee doubled with information known about the company
|
||
2. Attempt to at your best effort further the companies success and growth potential
|
||
3. Provide clear, concise, and actionable recommendations for improvement
|
||
4. Don't cater to sugarcoating or vague generalities
|
||
5. Beat the nail into the coffin of inefficiency with precise solutions, getting rid of all weak points.
|
||
|
||
Provide helpful, actionable insights while maintaining professional tone and focusing on critical must-know knowledge and actionable recommendations.
|
||
`.trim();
|
||
|
||
// Build the user message content
|
||
let userContent = [
|
||
{
|
||
type: "text",
|
||
text: message
|
||
}
|
||
];
|
||
|
||
// Add image attachments if present
|
||
if (attachments && attachments.length > 0) {
|
||
attachments.forEach(attachment => {
|
||
if (attachment.type.startsWith('image/') && attachment.data) {
|
||
userContent.push({
|
||
type: "image_url",
|
||
image_url: {
|
||
url: attachment.data,
|
||
detail: "high"
|
||
}
|
||
});
|
||
}
|
||
// For non-image files, add them as text context
|
||
else if (attachment.data) {
|
||
userContent.push({
|
||
type: "text",
|
||
text: `[Attached file: ${attachment.name} (${attachment.type})]`
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
const completion = await openai.chat.completions.create({
|
||
model: "gpt-4o",
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: systemPrompt
|
||
},
|
||
{
|
||
role: "user",
|
||
content: userContent
|
||
}
|
||
],
|
||
temperature: 0.7,
|
||
max_tokens: 1000, // Increased for more detailed responses when analyzing images
|
||
});
|
||
|
||
response = completion.choices[0].message.content;
|
||
} else {
|
||
// Fallback responses when OpenAI is not available
|
||
const attachmentText = attachments && attachments.length > 0
|
||
? ` I can see you've attached ${attachments.length} file(s), but I'm currently unable to process attachments.`
|
||
: '';
|
||
|
||
const responses = [
|
||
`That's an interesting point about performance metrics.${attachmentText} Based on the data, I'd recommend focusing on...`,
|
||
`I can see from the employee report that there are opportunities for growth in...${attachmentText}`,
|
||
`The company analysis suggests that this area needs attention.${attachmentText} Here's what I would suggest...`,
|
||
`Based on the performance data, this employee shows strong potential in...${attachmentText}`,
|
||
];
|
||
|
||
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 = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
const { name } = req.body;
|
||
|
||
if (!name) {
|
||
return res.status(400).json({ error: "Organization name is required" });
|
||
}
|
||
// 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: authContext.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(authContext.userId);
|
||
const userDoc = await userRef.get();
|
||
|
||
if (!userDoc.exists) {
|
||
console.error("User document not found:", authContext.userId);
|
||
return res.status(400).json({ error: "User not found" });
|
||
}
|
||
|
||
const userData = userDoc.data();
|
||
|
||
// Add owner info to organization document (owners are NOT employees)
|
||
const ownerInfo = {
|
||
id: authContext.userId,
|
||
name: userData.displayName || userData.email.split("@")[0],
|
||
email: userData.email,
|
||
joinedAt: Date.now()
|
||
};
|
||
|
||
// Update org document with owner info
|
||
await orgRef.update({
|
||
ownerInfo: ownerInfo,
|
||
updatedAt: Date.now()
|
||
});
|
||
|
||
// Add organization to user's organizations (for multi-org support)
|
||
const userOrgRef = db.collection("users").doc(authContext.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 = onRequest({ cors: true }, async (req, res) => {
|
||
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
// Get user's organizations
|
||
const userOrgsSnapshot = await db
|
||
.collection("users")
|
||
.doc(authContext.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);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to get user organizations" });
|
||
}
|
||
});
|
||
|
||
// Join Organization Function (via invite)
|
||
exports.joinOrganization = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
const { inviteCode } = req.body;
|
||
|
||
if (!inviteCode) {
|
||
return res.status(400).json({ error: "Invite code is required" });
|
||
}
|
||
// 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(authContext.userId);
|
||
const userDoc = await userRef.get();
|
||
|
||
if (!userDoc.exists) {
|
||
console.error("User document not found:", authContext.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: authContext.userId,
|
||
consumedAt: Date.now(),
|
||
});
|
||
|
||
// Add user to organization employees with full information
|
||
await db
|
||
.collection("orgs")
|
||
.doc(orgId)
|
||
.collection("employees")
|
||
.doc(authContext.userId)
|
||
.set({
|
||
id: authContext.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(authContext.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 = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
const { userEmail, priceId } = req.body;
|
||
|
||
if (!userEmail) {
|
||
return res.status(400).json({ error: "User email is required" });
|
||
}
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
if (!stripe) {
|
||
return res.status(500).json({ error: "Stripe not configured" });
|
||
}
|
||
|
||
// 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: authContext.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:5173'}/#/dashboard?session_id={CHECKOUT_SESSION_ID}`,
|
||
cancel_url: `${process.env.CLIENT_URL || 'http://localhost:5173'}/#/dashboard?canceled=true`,
|
||
metadata: {
|
||
orgId,
|
||
userId: authContext.userId,
|
||
},
|
||
subscription_data: {
|
||
metadata: {
|
||
orgId,
|
||
userId: authContext.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 = 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.rawBody, 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 = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
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' });
|
||
// }
|
||
// });
|
||
|
||
// Save Company Report Function
|
||
exports.saveCompanyReport = onRequest({ cors: true }, async (req, 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" });
|
||
}
|
||
});
|
||
|
||
// Helper function to verify user authorization
|
||
const verifyUserAuthorization = async (userId, orgId) => {
|
||
if (!userId || !orgId) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Check if user exists in the organization's employees collection
|
||
const employeeDoc = await db.collection("orgs").doc(orgId).collection("employees").doc(userId).get();
|
||
return employeeDoc.exists;
|
||
} catch (error) {
|
||
console.error("Authorization check error:", error);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Get Organization Data Function
|
||
exports.getOrgData = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get organization data
|
||
const orgDoc = await db.collection("orgs").doc(orgId).get();
|
||
if (!orgDoc.exists) {
|
||
return res.status(404).json({ error: "Organization not found" });
|
||
}
|
||
|
||
const orgData = { id: orgId, ...orgDoc.data() };
|
||
|
||
res.json({
|
||
success: true,
|
||
org: orgData
|
||
});
|
||
} catch (error) {
|
||
console.error("Get org data error:", error);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to get organization data" });
|
||
}
|
||
});
|
||
|
||
// Update Organization Data Function
|
||
exports.updateOrgData = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "PUT") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
const { data } = req.body;
|
||
|
||
if (!data) {
|
||
return res.status(400).json({ error: "Data is required" });
|
||
}
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Update organization data
|
||
const orgRef = db.collection("orgs").doc(orgId);
|
||
await orgRef.update({
|
||
...data,
|
||
updatedAt: Date.now()
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
message: "Organization data updated successfully"
|
||
});
|
||
} catch (error) {
|
||
console.error("Update org data error:", error);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to update organization data" });
|
||
}
|
||
});
|
||
|
||
// Get Employees Function
|
||
exports.getEmployees = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all employees (excluding owners - they should not be in employees collection)
|
||
const employeesSnapshot = await db.collection("orgs").doc(orgId).collection("employees").get();
|
||
const employees = [];
|
||
|
||
employeesSnapshot.forEach(doc => {
|
||
const employeeData = doc.data();
|
||
// Skip any owner records that might still exist (defensive programming)
|
||
if (employeeData.role !== "owner" && !employeeData.isOwner) {
|
||
employees.push({ id: doc.id, ...employeeData });
|
||
}
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
employees
|
||
});
|
||
} catch (error) {
|
||
console.error("Get employees error:", error);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to get employees" });
|
||
}
|
||
});
|
||
|
||
// Get Submissions Function
|
||
exports.getSubmissions = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all submissions
|
||
const submissionsSnapshot = await db.collection("orgs").doc(orgId).collection("submissions").get();
|
||
const submissions = {};
|
||
|
||
submissionsSnapshot.forEach(doc => {
|
||
submissions[doc.id] = { id: doc.id, ...doc.data() };
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
submissions
|
||
});
|
||
} catch (error) {
|
||
console.error("Get submissions error:", error);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to get submissions" });
|
||
}
|
||
});
|
||
|
||
// Get Reports Function
|
||
exports.getReports = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all reports
|
||
const reportsSnapshot = await db.collection("orgs").doc(orgId).collection("reports").get();
|
||
const reports = {};
|
||
|
||
reportsSnapshot.forEach(doc => {
|
||
reports[doc.id] = { id: doc.id, ...doc.data() };
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
reports
|
||
});
|
||
} catch (error) {
|
||
console.error("Get reports error:", error);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to get reports" });
|
||
}
|
||
});
|
||
|
||
// Create/Update Employee Function
|
||
exports.upsertEmployee = onRequest({ cors: true }, async (req, 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, employeeData } = req.body;
|
||
|
||
if (!orgId || !userId || !employeeData) {
|
||
return res.status(400).json({ error: "Organization ID, user ID, and employee data are required" });
|
||
}
|
||
|
||
try {
|
||
// Verify user authorization
|
||
const isAuthorized = await verifyUserAuthorization(userId, orgId);
|
||
if (!isAuthorized) {
|
||
return res.status(403).json({ error: "Unauthorized access to organization" });
|
||
}
|
||
|
||
// Generate employee ID if not provided
|
||
if (!employeeData.id) {
|
||
employeeData.id = `emp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
// Add timestamps
|
||
const currentTime = Date.now();
|
||
if (!employeeData.createdAt) {
|
||
employeeData.createdAt = currentTime;
|
||
}
|
||
employeeData.updatedAt = currentTime;
|
||
|
||
// Save employee
|
||
const employeeRef = db.collection("orgs").doc(orgId).collection("employees").doc(employeeData.id);
|
||
await employeeRef.set(employeeData);
|
||
|
||
res.json({
|
||
success: true,
|
||
employee: employeeData,
|
||
message: "Employee saved successfully"
|
||
});
|
||
} catch (error) {
|
||
console.error("Upsert employee error:", error);
|
||
res.status(500).json({ error: "Failed to save employee" });
|
||
}
|
||
});
|
||
|
||
// Save Report Function
|
||
exports.saveReport = onRequest({ cors: true }, async (req, 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, employeeId, reportData } = req.body;
|
||
|
||
if (!orgId || !userId || !employeeId || !reportData) {
|
||
return res.status(400).json({ error: "Organization ID, user ID, employee ID, and report data are required" });
|
||
}
|
||
|
||
try {
|
||
// Verify user authorization
|
||
const isAuthorized = await verifyUserAuthorization(userId, orgId);
|
||
if (!isAuthorized) {
|
||
return res.status(403).json({ error: "Unauthorized access to organization" });
|
||
}
|
||
|
||
// Add metadata
|
||
const currentTime = Date.now();
|
||
if (!reportData.id) {
|
||
reportData.id = `report_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
if (!reportData.createdAt) {
|
||
reportData.createdAt = currentTime;
|
||
}
|
||
reportData.updatedAt = currentTime;
|
||
reportData.employeeId = employeeId;
|
||
|
||
// Save report
|
||
const reportRef = db.collection("orgs").doc(orgId).collection("reports").doc(employeeId);
|
||
await reportRef.set(reportData);
|
||
|
||
res.json({
|
||
success: true,
|
||
report: reportData,
|
||
message: "Report saved successfully"
|
||
});
|
||
} catch (error) {
|
||
console.error("Save report error:", error);
|
||
res.status(500).json({ error: "Failed to save report" });
|
||
}
|
||
});
|
||
|
||
// Get Company Reports Function
|
||
exports.getCompanyReports = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all company reports
|
||
const reportsSnapshot = await db.collection("orgs").doc(orgId).collection("companyReport").doc("main").get();
|
||
|
||
const reportsData = reportsSnapshot.data();
|
||
const reports = reportsData ? [reportsData] : [];
|
||
|
||
// Convert the reports object to an array
|
||
// for (const [id, report] of Object.entries(reportsData || {})) {
|
||
// reports.push({ id, ...report });
|
||
// }
|
||
// Sort by creation date (newest first)
|
||
reports.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||
|
||
res.json({
|
||
success: true,
|
||
reports
|
||
});
|
||
} catch (error) {
|
||
console.error("Get company reports error:", error);
|
||
res.status(500).json({ error: "Failed to get company reports" });
|
||
}
|
||
});
|
||
|
||
// Upload Image Function
|
||
exports.uploadImage = onRequest({ cors: true }, async (req, 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, imageData } = req.body;
|
||
|
||
if (!orgId || !userId || !imageData) {
|
||
return res.status(400).json({ error: "Organization ID, user ID, and image data are required" });
|
||
}
|
||
|
||
try {
|
||
// Verify user authorization
|
||
const isAuthorized = await verifyUserAuthorization(userId, orgId);
|
||
if (!isAuthorized) {
|
||
return res.status(403).json({ error: "Unauthorized access to organization" });
|
||
}
|
||
|
||
// Validate image data
|
||
const { collectionName, documentId, dataUrl, filename, originalSize, compressedSize, width, height } = imageData;
|
||
|
||
if (!collectionName || !documentId || !dataUrl || !filename) {
|
||
return res.status(400).json({ error: "Missing required image data fields" });
|
||
}
|
||
|
||
// Create image document
|
||
const imageDoc = {
|
||
id: `${Date.now()}_${filename}`,
|
||
dataUrl,
|
||
filename,
|
||
originalSize: originalSize || 0,
|
||
compressedSize: compressedSize || 0,
|
||
uploadedAt: Date.now(),
|
||
width: width || 0,
|
||
height: height || 0,
|
||
orgId,
|
||
uploadedBy: userId
|
||
};
|
||
|
||
// Store image in organization's images collection
|
||
const imageRef = db.collection("orgs").doc(orgId).collection("images").doc(`${collectionName}_${documentId}`);
|
||
await imageRef.set({
|
||
...imageDoc,
|
||
collectionName,
|
||
documentId
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
imageId: imageDoc.id,
|
||
message: "Image uploaded successfully"
|
||
});
|
||
} catch (error) {
|
||
console.error("Upload image error:", error);
|
||
res.status(500).json({ error: "Failed to upload image" });
|
||
}
|
||
});
|
||
|
||
// Get Image Function
|
||
exports.getImage = onRequest({ cors: true }, async (req, 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, userId, collectionName, documentId } = req.query;
|
||
|
||
if (!orgId || !userId || !collectionName || !documentId) {
|
||
return res.status(400).json({ error: "Organization ID, user ID, collection name, and document ID are required" });
|
||
}
|
||
|
||
try {
|
||
// Verify user authorization
|
||
const isAuthorized = await verifyUserAuthorization(userId, orgId);
|
||
if (!isAuthorized) {
|
||
return res.status(403).json({ error: "Unauthorized access to organization" });
|
||
}
|
||
|
||
// Get image document
|
||
const imageRef = db.collection("orgs").doc(orgId).collection("images").doc(`${collectionName}_${documentId}`);
|
||
const imageDoc = await imageRef.get();
|
||
|
||
if (!imageDoc.exists) {
|
||
return res.status(404).json({ error: "Image not found" });
|
||
}
|
||
|
||
const imageData = imageDoc.data();
|
||
|
||
// Return image data (excluding org-specific metadata)
|
||
const responseData = {
|
||
id: imageData.id,
|
||
dataUrl: imageData.dataUrl,
|
||
filename: imageData.filename,
|
||
originalSize: imageData.originalSize,
|
||
compressedSize: imageData.compressedSize,
|
||
uploadedAt: imageData.uploadedAt,
|
||
width: imageData.width,
|
||
height: imageData.height
|
||
};
|
||
|
||
res.json({
|
||
success: true,
|
||
image: responseData
|
||
});
|
||
} catch (error) {
|
||
console.error("Get image error:", error);
|
||
res.status(500).json({ error: "Failed to get image" });
|
||
}
|
||
});
|
||
|
||
// Delete Image Function
|
||
exports.deleteImage = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "DELETE") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
const { orgId, userId, collectionName, documentId } = req.body;
|
||
|
||
if (!orgId || !userId || !collectionName || !documentId) {
|
||
return res.status(400).json({ error: "Organization ID, user ID, collection name, and document ID are required" });
|
||
}
|
||
|
||
try {
|
||
// Verify user authorization
|
||
const isAuthorized = await verifyUserAuthorization(userId, orgId);
|
||
if (!isAuthorized) {
|
||
return res.status(403).json({ error: "Unauthorized access to organization" });
|
||
}
|
||
|
||
// Delete image document
|
||
const imageRef = db.collection("orgs").doc(orgId).collection("images").doc(`${collectionName}_${documentId}`);
|
||
const imageDoc = await imageRef.get();
|
||
|
||
if (!imageDoc.exists) {
|
||
return res.status(404).json({ error: "Image not found" });
|
||
}
|
||
|
||
await imageRef.delete();
|
||
|
||
res.json({
|
||
success: true,
|
||
message: "Image deleted successfully"
|
||
});
|
||
} catch (error) {
|
||
console.error("Delete image error:", error);
|
||
res.status(500).json({ error: "Failed to delete image" });
|
||
}
|
||
});
|
||
|
||
// Migration Function - Remove Owners from Employees Collection
|
||
exports.migrateOwnersFromEmployees = onRequest({ cors: true }, async (req, res) => {
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).send('');
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Validate auth token and get user context
|
||
const authContext = await validateAuthAndGetContext(req);
|
||
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get organization document
|
||
const orgRef = db.collection("orgs").doc(orgId);
|
||
const orgDoc = await orgRef.get();
|
||
|
||
if (!orgDoc.exists) {
|
||
return res.status(404).json({ error: "Organization not found" });
|
||
}
|
||
|
||
const orgData = orgDoc.data();
|
||
let migratedCount = 0;
|
||
let ownerInfo = null;
|
||
|
||
// Find and remove any owners from employees collection
|
||
const employeesSnapshot = await orgRef.collection("employees").where("role", "==", "owner").get();
|
||
|
||
if (!employeesSnapshot.empty) {
|
||
// Get owner info before deletion
|
||
const ownerEmployee = employeesSnapshot.docs[0].data();
|
||
ownerInfo = {
|
||
id: ownerEmployee.id,
|
||
name: ownerEmployee.name,
|
||
email: ownerEmployee.email,
|
||
joinedAt: ownerEmployee.joinedAt || Date.now()
|
||
};
|
||
|
||
// Delete all owner records from employees collection
|
||
const batch = db.batch();
|
||
employeesSnapshot.docs.forEach(doc => {
|
||
batch.delete(doc.ref);
|
||
migratedCount++;
|
||
});
|
||
await batch.commit();
|
||
}
|
||
|
||
// Update org document with owner info if we found it
|
||
if (ownerInfo) {
|
||
await orgRef.update({
|
||
ownerInfo: ownerInfo,
|
||
updatedAt: Date.now()
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
message: `Migration completed. Removed ${migratedCount} owner record(s) from employees collection.`,
|
||
migratedCount,
|
||
ownerInfo
|
||
});
|
||
} catch (error) {
|
||
console.error("Migration error:", error);
|
||
if (error.message.includes('Missing or invalid authorization') ||
|
||
error.message.includes('Token')) {
|
||
return res.status(401).json({ error: error.message });
|
||
}
|
||
res.status(500).json({ error: "Failed to complete migration" });
|
||
}
|
||
}); |