2784 lines
93 KiB
JavaScript
2784 lines
93 KiB
JavaScript
const { onRequest } = require("firebase-functions/v2/https");
|
||
const { setGlobalOptions } = require("firebase-functions/v2");
|
||
const admin = require("firebase-admin");
|
||
const { VertexAI } = require('@google-cloud/vertexai');
|
||
const Stripe = require("stripe");
|
||
|
||
// Set global options for all functions to use us-central1 region
|
||
setGlobalOptions({ cors: true });
|
||
|
||
const serviceAccount = require("./auditly-consulting-firebase-adminsdk-fbsvc-e4b51ef5cf.json");
|
||
// const serviceAccount = require("./auditly-c0027-firebase-adminsdk-fbsvc-1db7c58141.json")
|
||
|
||
admin.initializeApp({
|
||
credential: admin.credential.cert(serviceAccount)
|
||
});
|
||
|
||
//region Interface Clients
|
||
const db = admin.firestore();
|
||
|
||
// Initialize Vertex AI with your project ID
|
||
// This automatically uses IAM authentication from the service account
|
||
const vertexAI = new VertexAI({
|
||
project: 'auditly-consulting', // Using the project ID from the service account
|
||
location: 'us-central1'
|
||
});
|
||
|
||
// Get the Gemini model
|
||
const geminiModel = vertexAI.getGenerativeModel({
|
||
model: 'gemini-2.5-flash',
|
||
generationConfig: {
|
||
maxOutputTokens: 8192,
|
||
temperature: 0.7,
|
||
topP: 0.8,
|
||
topK: 10
|
||
}
|
||
});
|
||
|
||
// 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;
|
||
//endregion Interface Clients
|
||
|
||
//region Constants
|
||
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."
|
||
]
|
||
]
|
||
},
|
||
weaknesses: {
|
||
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" }
|
||
}
|
||
}
|
||
}
|
||
|
||
const 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"
|
||
]
|
||
}
|
||
//endregion Constants
|
||
|
||
//region Helper Functions
|
||
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
|
||
};
|
||
};
|
||
|
||
const generateOTP = () => {
|
||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||
};
|
||
|
||
async function generateWithGemini(prompt, responseFormat = null) {
|
||
try {
|
||
let fullPrompt = prompt;
|
||
|
||
if (responseFormat) {
|
||
fullPrompt += `\n\nIMPORTANT: Return ONLY valid JSON that matches this exact schema:\n${JSON.stringify(responseFormat, null, 2)}`;
|
||
}
|
||
|
||
const result = await geminiModel.generateContent({
|
||
contents: [{
|
||
role: 'user',
|
||
parts: [{ text: fullPrompt }]
|
||
}]
|
||
});
|
||
|
||
const response = result.response;
|
||
let content = response.text();
|
||
|
||
// Clean up the response if it has markdown formatting
|
||
if (content.includes('```json')) {
|
||
content = content.replace(/```json\n?/g, '').replace(/```\n?/g, '');
|
||
}
|
||
|
||
// If we expect JSON, parse and validate it
|
||
if (responseFormat) {
|
||
try {
|
||
return JSON.parse(content.trim());
|
||
} catch (parseError) {
|
||
console.error('Failed to parse Gemini JSON response:', parseError);
|
||
console.error('Raw response:', content);
|
||
throw new Error('Invalid JSON response from Gemini');
|
||
}
|
||
}
|
||
|
||
return content;
|
||
} catch (error) {
|
||
console.error('Gemini API error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
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(),
|
||
});
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
};
|
||
//endregion Helper Functions
|
||
|
||
//region Send OTP
|
||
exports.sendOTP = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Send OTP
|
||
|
||
//region Verify OTP
|
||
exports.verifyOTP = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Verify OTP
|
||
|
||
//region Create Invitation
|
||
exports.createInvitation = onRequest(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);
|
||
|
||
const inviteReff = await db
|
||
.collection("invites")
|
||
.doc(code)
|
||
.set({
|
||
code,
|
||
employee,
|
||
email,
|
||
orgId,
|
||
status: "pending",
|
||
createdAt: Date.now(),
|
||
expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), // 7 days
|
||
});
|
||
|
||
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" });
|
||
}
|
||
});
|
||
//endregion Create Invitation
|
||
|
||
//region Get Invitation Status
|
||
exports.getInvitationStatus = onRequest(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 inviteDocc = await db
|
||
.collection("invites")
|
||
.doc(code)
|
||
.get();
|
||
|
||
if (!inviteDocc.exists) {
|
||
return res.status(404).json({ error: "Invitation not found" });
|
||
}
|
||
|
||
const invitee = inviteDocc.data();
|
||
|
||
const inviteDoc = await db
|
||
.collection("orgs")
|
||
.doc(invitee.orgId)
|
||
.collection("invites")
|
||
.doc(code)
|
||
.get();
|
||
|
||
const invite = inviteDoc.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" });
|
||
}
|
||
});
|
||
//endregion Get Invitation Status
|
||
|
||
//region Consume Invitation
|
||
exports.consumeInvitation = onRequest(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 inviteSnapshott = await db
|
||
.collection("invites")
|
||
.doc(code)
|
||
.get();
|
||
if (!inviteSnapshott.exists) {
|
||
return res.status(404).json({ error: "Invitation not found" });
|
||
}
|
||
|
||
const invitee = inviteSnapshott.data();
|
||
|
||
const inviteSnapshot = await db
|
||
.collection("orgs")
|
||
.doc(invitee.orgId)
|
||
.collection("invites")
|
||
.doc(code)
|
||
.get();
|
||
|
||
if (!inviteSnapshot.exists) {
|
||
return res.status(404).json({ error: "Invitation not found or already used" });
|
||
}
|
||
|
||
const invite = inviteSnapshot.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 inviteSnapshott.ref.update({
|
||
status: "consumed",
|
||
consumedBy: employee.id,
|
||
consumedAt: Date.now(),
|
||
});
|
||
await inviteSnapshot.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" });
|
||
}
|
||
});
|
||
//endregion Consume Invitation
|
||
|
||
//region Submit Employee Answers
|
||
exports.submitEmployeeAnswers = onRequest(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" });
|
||
}
|
||
|
||
const inviteSnapshott = await db.collection("invites").doc(inviteCode).get();
|
||
|
||
if (!inviteSnapshott.exists) {
|
||
return res.status(404).json({ error: "Invitation not found" });
|
||
}
|
||
|
||
const invitee = inviteSnapshott.data();
|
||
|
||
// Look up the invite to get employee and org data (should be pending, not consumed yet)
|
||
const inviteSnapshot = await db
|
||
.collection("orgs")
|
||
.doc(invitee.orgId)
|
||
.collection("invites")
|
||
.doc(inviteCode)
|
||
.get();
|
||
|
||
if (!inviteSnapshot.exists) {
|
||
return res.status(404).json({ error: "Invitation not found or already used" });
|
||
}
|
||
|
||
const invite = inviteSnapshot.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;
|
||
|
||
invitee.employee.status = "active";
|
||
invite.employee.status = "active";
|
||
|
||
await inviteSnapshott.ref.update({
|
||
status: "consumed",
|
||
consumedBy: finalEmployeeId,
|
||
consumedAt: Date.now(),
|
||
employee: invitee.employee
|
||
});
|
||
|
||
// Consume the invitation now
|
||
await inviteSnapshot.ref.update({
|
||
status: "consumed",
|
||
consumedBy: finalEmployeeId,
|
||
consumedAt: Date.now(),
|
||
employee: invite.employee
|
||
});
|
||
|
||
// 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;
|
||
try {
|
||
// Use Gemini to generate the report with company context
|
||
const prompt = `
|
||
You are a cut-and-dry expert business analyst. Analyze the following employee data and generate a comprehensive performance report.
|
||
|
||
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
|
||
|
||
Be thorough, professional, and focus on actionable insights.
|
||
`.trim();
|
||
|
||
const parsedReport = await generateWithGemini(prompt, RESPONSE_FORMAT_EMPLOYEE);
|
||
|
||
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
|
||
};
|
||
} catch (aiError) {
|
||
console.error("Gemini report generation error:", aiError);
|
||
// Fallback to basic report structure
|
||
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" });
|
||
}
|
||
});
|
||
//endregion Submit Employee Answers
|
||
|
||
//region Generate Employee Report
|
||
exports.generateEmployeeReport = onRequest(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;
|
||
|
||
try {
|
||
// Use Gemini to generate the report
|
||
const prompt = `
|
||
You are a cut-and-dry expert business analyst. Analyze the following employee data and generate a comprehensive performance report.
|
||
|
||
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
|
||
|
||
Be thorough, professional, and focus on actionable insights.
|
||
`.trim();
|
||
|
||
const parsedReport = await generateWithGemini(prompt, RESPONSE_FORMAT_EMPLOYEE);
|
||
|
||
report = {
|
||
employeeId: employee.id,
|
||
generatedAt: Date.now(),
|
||
summary: `AI-generated performance analysis for ${employee.name || employee.email}`,
|
||
...parsedReport
|
||
};
|
||
} catch (aiError) {
|
||
console.error("Gemini AI error:", aiError);
|
||
// Fallback to mock report when Gemini 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" });
|
||
}
|
||
});
|
||
//endregion Generate Employee Report
|
||
|
||
//region Generate Company Report
|
||
exports.generateCompanyReport = onRequest(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;
|
||
|
||
try {
|
||
// Use Gemini to generate the company report
|
||
const prompt = `You are a cut-and-dry expert business analyst who shys to no truths and will get a business in tip-top shape with swiftness. Analyze the following company data and generate a comprehensive organizational report.
|
||
|
||
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.
|
||
|
||
Be thorough, professional, and focus on actionable insights.`;
|
||
|
||
const parsed = await generateWithGemini(prompt, RESPONSE_FORMAT_COMPANY);
|
||
|
||
report = {
|
||
generatedAt: Date.now(),
|
||
...parsed
|
||
};
|
||
|
||
// Save the report
|
||
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
|
||
});
|
||
|
||
} catch (aiError) {
|
||
console.error("Gemini AI error:", aiError);
|
||
// Fallback to mock data when Gemini 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" });
|
||
}
|
||
});
|
||
//endregion Generate Company Report
|
||
|
||
//region Chat
|
||
exports.chat = onRequest(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;
|
||
|
||
try {
|
||
// Use Gemini 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')}
|
||
` : ''}
|
||
|
||
Instructions:
|
||
- Provide specific, actionable business advice
|
||
- Reference the provided context when relevant
|
||
- Be direct and professional
|
||
- Focus on practical solutions and insights
|
||
- Keep responses concise but thorough
|
||
- When discussing employees, use their names and be respectful`;
|
||
|
||
let fullMessage = message;
|
||
|
||
// Handle file attachments (text only for now with Gemini)
|
||
if (attachments && attachments.length > 0) {
|
||
const textAttachments = attachments.filter(att =>
|
||
att.type?.includes('text') || att.name?.endsWith('.txt') || att.name?.endsWith('.md')
|
||
);
|
||
|
||
if (textAttachments.length > 0) {
|
||
fullMessage += `\n\nAttached files:\n${textAttachments.map(att =>
|
||
`- ${att.name}: ${att.data || '[File content not available]'}`
|
||
).join('\n')}`;
|
||
}
|
||
}
|
||
|
||
const prompt = `${systemPrompt}\n\nUser question: ${fullMessage}`;
|
||
|
||
response = await generateWithGemini(prompt);
|
||
|
||
} catch (aiError) {
|
||
console.error("Gemini AI error:", aiError);
|
||
// Fallback responses when Gemini 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 = [
|
||
`I understand you're asking about "${message}". Based on your team structure, I'd recommend focusing on clear communication and setting measurable goals.${attachmentText}`,
|
||
`That's an interesting question about "${message}". Consider reviewing your current processes and identifying areas for improvement.${attachmentText}`,
|
||
`Regarding "${message}" - this touches on important organizational dynamics. I'd suggest gathering more data and consulting with your team leads.${attachmentText}`,
|
||
`Your question about "${message}" highlights key business considerations. Focus on data-driven decisions and stakeholder alignment.${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" });
|
||
}
|
||
});
|
||
//endregion Chat
|
||
|
||
//region Create Organization
|
||
exports.createOrganization = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Create Organization
|
||
|
||
//region Get Organizations
|
||
exports.getUserOrganizations = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Get Organizations
|
||
|
||
//region Join Organization
|
||
exports.joinOrganization = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Join Organization
|
||
|
||
//region Create Checkout Session
|
||
// exports.createCheckoutSession = onRequest(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" });
|
||
// }
|
||
// });
|
||
// //endregion Create Checkout Session
|
||
|
||
// //region Stripe Webhook
|
||
// 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' });
|
||
// }
|
||
// });
|
||
//endregion Stripe Webhook
|
||
|
||
//region Get Sub Status
|
||
// exports.getSubscriptionStatus = onRequest(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" });
|
||
// }
|
||
// });
|
||
//endregion Get Sub Status
|
||
|
||
//region Save Company Report
|
||
// exports.saveCompanyReport = onRequest(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" });
|
||
// }
|
||
// });
|
||
//endregion Save Company Report
|
||
|
||
//region Get Org Data
|
||
exports.getOrgData = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Get Org Data
|
||
|
||
//region Update Organization Data
|
||
exports.updateOrgData = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Update Organization Data
|
||
|
||
//region Get Employees
|
||
exports.getEmployees = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Get Employees
|
||
|
||
//region Get Submissions
|
||
exports.getSubmissions = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Get Submissions
|
||
|
||
//region Get Reports
|
||
exports.getReports = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Get Reports
|
||
|
||
//region Create/Update Employee
|
||
// exports.upsertEmployee = onRequest(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" });
|
||
// }
|
||
// });
|
||
//endregion Create/Update Employee
|
||
|
||
//region Save Report
|
||
exports.saveReport = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Save Report
|
||
|
||
//region Get Company Reports
|
||
exports.getCompanyReports = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Get Company Reports
|
||
|
||
//region Upload Image
|
||
exports.uploadImage = onRequest(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" });
|
||
}
|
||
});
|
||
//endregion Upload Image
|
||
|
||
//region Get Image
|
||
// exports.getImage = onRequest(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" });
|
||
// }
|
||
// });
|
||
//endregion Get Image
|
||
|
||
//region Delete Image
|
||
// exports.deleteImage = onRequest(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" });
|
||
// }
|
||
// });
|
||
//endregion Delete Image
|