3265 lines
117 KiB
JavaScript
3265 lines
117 KiB
JavaScript
const { onRequest } = require("firebase-functions/v2/https");
|
||
const { setGlobalOptions, logger } = require("firebase-functions/v2");
|
||
const { VertexAI } = require('@google-cloud/vertexai');
|
||
const Stripe = require("stripe");
|
||
const nodemailer = require('nodemailer');
|
||
const { executeQuery, executeTransaction } = require('./database');
|
||
|
||
// Set global options for all functions to use us-central1 region
|
||
setGlobalOptions({ region: "us-central1" });
|
||
|
||
// Initialize Vertex AI with your project ID
|
||
// This automatically uses IAM authentication from the service account
|
||
const vertexAI = new VertexAI({
|
||
project: 'auditly-c0027', // 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;
|
||
|
||
// Initialize nodemailer transporter
|
||
const emailTransporter = process.env.EMAIL_HOST ? nodemailer.createTransport({
|
||
host: process.env.EMAIL_HOST,
|
||
port: parseInt(process.env.EMAIL_PORT || '465'),
|
||
secure: true, // Use SSL
|
||
auth: {
|
||
user: process.env.EMAIL_USER,
|
||
pass: process.env.EMAIL_PASS
|
||
}
|
||
}) : 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" },
|
||
description: "Do not include the detail's index in the operating plan, instead, just provide the details",
|
||
example: [
|
||
"Implement Comprehensive Data Collection: Immediately deploy a mandatory, company-wide survey to ALL employees (not just one). Ensure robust data collection for all 'Company Context' fields currently marked 'test'. Establish ongoing feedback loops.",
|
||
"Define Core Strategy: Conduct intensive leadership workshops to articulate and formalize the company's Mission, Vision, Strategic Advantages, and Unique Offering. These must be concrete, measurable, and communicated company-wide.",
|
||
"Map Current State Operations: Document existing business systems, workflows, and processes. Identify personal dependencies, operational friction points, fragile processes, and recurring problems. This requires direct observation and interviews, not just surveys.",
|
||
"Financial Health Assessment: Engage an external financial consultant if internal expertise is lacking to establish clear financial reporting, analyze revenue pipeline, customer acquisition costs, LTV, burn rate, and profit margins. No more 'test' financials."
|
||
]
|
||
}
|
||
},
|
||
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
|
||
async function validateAuthAndGetContext(req, res) {
|
||
// Set CORS headers for all requests
|
||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||
.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||
.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
||
|
||
if (req.method === "OPTIONS") {
|
||
res.status(204).send('');
|
||
return null;
|
||
}
|
||
|
||
const authHeader = req.headers.authorization;
|
||
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
res.status(401).json({ error: 'Missing or invalid authorization header' });
|
||
return null;
|
||
}
|
||
|
||
const token = authHeader.substring(7);
|
||
|
||
// Validate token format more thoroughly
|
||
if (!token.startsWith('session_') || token.length < 20) {
|
||
res.status(401).json({ error: 'Invalid token format' });
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
// Look up token in PostgreSQL
|
||
const tokenRows = await executeQuery(
|
||
'SELECT * FROM auth_tokens WHERE token = $1 AND is_active = true',
|
||
[token]
|
||
);
|
||
|
||
if (tokenRows.length === 0) {
|
||
res.status(401).json({ error: 'Token not found' });
|
||
return null;
|
||
}
|
||
|
||
const tokenData = tokenRows[0];
|
||
|
||
if (Date.now() > tokenData.expires_at) {
|
||
res.status(401).json({ error: 'Token has expired' });
|
||
return null;
|
||
}
|
||
|
||
// Update last used timestamp (don't await to avoid blocking)
|
||
executeQuery(
|
||
'UPDATE auth_tokens SET last_used_at = $1 WHERE token = $2',
|
||
[Date.now(), token]
|
||
).catch(error => {
|
||
console.warn('Failed to update token lastUsedAt:', error);
|
||
});
|
||
|
||
// Get user's organizations
|
||
const userOrgsRows = await executeQuery(
|
||
'SELECT organization_id FROM user_organizations WHERE user_id = $1',
|
||
[tokenData.user_id]
|
||
);
|
||
|
||
const orgIds = userOrgsRows.map(row => row.organization_id);
|
||
|
||
return {
|
||
userId: tokenData.user_id,
|
||
orgIds: orgIds,
|
||
orgId: orgIds[0] || null,
|
||
token: token
|
||
};
|
||
} catch (error) {
|
||
console.error('Auth validation error:', error);
|
||
res.status(500).json({ error: 'Authentication validation failed' });
|
||
return null;
|
||
}
|
||
};
|
||
|
||
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 responseContent = "";
|
||
for (const candidate of response.candidates[0].content.parts) {
|
||
responseContent += candidate.text;
|
||
}
|
||
let content = responseContent;
|
||
|
||
// 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;
|
||
|
||
await executeQuery(
|
||
`UPDATE organizations SET
|
||
subscription_status = $1,
|
||
stripe_subscription_id = $2,
|
||
checkout_session_id = $3,
|
||
updated_at = $4
|
||
WHERE id = $5`,
|
||
['trialing', session.subscription, session.id, Date.now(), orgId]
|
||
);
|
||
}
|
||
|
||
async function handleSubscriptionCreated(subscription) {
|
||
const orgId = subscription.metadata?.orgId;
|
||
if (!orgId) return;
|
||
|
||
await executeQuery(
|
||
`UPDATE organizations SET
|
||
subscription_status = $1,
|
||
stripe_subscription_id = $2,
|
||
current_period_start = $3,
|
||
current_period_end = $4,
|
||
trial_end = $5,
|
||
updated_at = $6
|
||
WHERE id = $7`,
|
||
[
|
||
subscription.status,
|
||
subscription.id,
|
||
subscription.current_period_start * 1000,
|
||
subscription.current_period_end * 1000,
|
||
subscription.trial_end ? subscription.trial_end * 1000 : null,
|
||
Date.now(),
|
||
orgId
|
||
]
|
||
);
|
||
}
|
||
|
||
async function handleSubscriptionUpdated(subscription) {
|
||
const orgId = subscription.metadata?.orgId;
|
||
if (!orgId) return;
|
||
|
||
await executeQuery(
|
||
`UPDATE organizations SET
|
||
subscription_status = $1,
|
||
current_period_start = $2,
|
||
current_period_end = $3,
|
||
trial_end = $4,
|
||
updated_at = $5
|
||
WHERE id = $6`,
|
||
[
|
||
subscription.status,
|
||
subscription.current_period_start * 1000,
|
||
subscription.current_period_end * 1000,
|
||
subscription.trial_end ? subscription.trial_end * 1000 : null,
|
||
Date.now(),
|
||
orgId
|
||
]
|
||
);
|
||
}
|
||
|
||
async function handleSubscriptionDeleted(subscription) {
|
||
const orgId = subscription.metadata?.orgId;
|
||
if (!orgId) return;
|
||
|
||
await executeQuery(
|
||
`UPDATE organizations SET
|
||
subscription_status = $1,
|
||
current_period_end = $2,
|
||
updated_at = $3
|
||
WHERE id = $4`,
|
||
['canceled', subscription.current_period_end * 1000, Date.now(), orgId]
|
||
);
|
||
}
|
||
|
||
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) {
|
||
await executeQuery(
|
||
`UPDATE organizations SET
|
||
subscription_status = $1,
|
||
current_period_start = $2,
|
||
current_period_end = $3,
|
||
updated_at = $4
|
||
WHERE id = $5`,
|
||
[
|
||
'active',
|
||
subscription.current_period_start * 1000,
|
||
subscription.current_period_end * 1000,
|
||
Date.now(),
|
||
orgId
|
||
]
|
||
);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
await executeQuery(
|
||
`UPDATE organizations SET
|
||
subscription_status = $1,
|
||
updated_at = $2
|
||
WHERE id = $3`,
|
||
['past_due', Date.now(), orgId]
|
||
);
|
||
}
|
||
}
|
||
|
||
const verifyUserAuthorization = async (userId, orgId) => {
|
||
if (!userId || !orgId) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Check if user exists in the organization's employees or as an organization member
|
||
const employeeRows = await executeQuery(
|
||
'SELECT id FROM employees WHERE organization_id = $1 AND (id = $2 OR user_id = $2)',
|
||
[orgId, userId]
|
||
);
|
||
|
||
const userOrgRows = await executeQuery(
|
||
'SELECT user_id FROM user_organizations WHERE user_id = $1 AND organization_id = $2',
|
||
[userId, orgId]
|
||
);
|
||
|
||
return employeeRows.length > 0 || userOrgRows.length > 0;
|
||
} catch (error) {
|
||
console.error("Authorization check error:", error);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// Email service helper function
|
||
const sendEmail = async (to, subject, htmlContent, textContent = null) => {
|
||
if (!emailTransporter) {
|
||
console.warn('Email transporter not configured. Email not sent.');
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const mailOptions = {
|
||
from: {
|
||
name: 'Auditly',
|
||
address: process.env.EMAIL_USER
|
||
},
|
||
to,
|
||
subject,
|
||
html: htmlContent,
|
||
text: textContent || htmlContent.replace(/<[^>]*>/g, '') // Strip HTML for text version
|
||
};
|
||
|
||
const result = await emailTransporter.sendMail(mailOptions);
|
||
console.log(`✅ Email sent successfully to ${to}: ${result.messageId}`);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('❌ Failed to send email:', error);
|
||
return false;
|
||
}
|
||
};
|
||
//endregion Helper Functions
|
||
|
||
//region Send OTP
|
||
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 PostgreSQL
|
||
await executeQuery(
|
||
`INSERT INTO otps (email, otp, expires_at, attempts, invite_code, created_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6)
|
||
ON CONFLICT (email)
|
||
DO UPDATE SET
|
||
otp = EXCLUDED.otp,
|
||
expires_at = EXCLUDED.expires_at,
|
||
attempts = 0,
|
||
invite_code = EXCLUDED.invite_code,
|
||
created_at = EXCLUDED.created_at`,
|
||
[email, otp, expiresAt, 0, inviteCode || null, Date.now()]
|
||
);
|
||
|
||
// Send OTP email
|
||
const subject = 'Your Auditly Verification Code';
|
||
const htmlContent = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<div style="text-align: center; margin-bottom: 30px;">
|
||
<h1 style="color: #2563eb; margin: 0;">Auditly</h1>
|
||
</div>
|
||
|
||
<div style="background-color: #f8fafc; padding: 30px; border-radius: 8px; margin-bottom: 20px;">
|
||
<h2 style="color: #1e293b; margin-top: 0;">Your Verification Code</h2>
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">
|
||
Please use the following verification code to complete your login:
|
||
</p>
|
||
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<span style="background-color: #2563eb; color: white; font-size: 32px; font-weight: bold; padding: 15px 25px; border-radius: 6px; letter-spacing: 5px;">
|
||
${otp}
|
||
</span>
|
||
</div>
|
||
|
||
<p style="color: #64748b; font-size: 14px;">
|
||
This code will expire in 5 minutes for security purposes.
|
||
</p>
|
||
</div>
|
||
|
||
<div style="text-align: center; color: #64748b; font-size: 12px;">
|
||
<p>If you didn't request this code, please ignore this email.</p>
|
||
<p>© 2024 Auditly. All rights reserved.</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const emailSent = await sendEmail(email, subject, htmlContent);
|
||
|
||
if (emailSent) {
|
||
console.log(`📧 OTP sent via email to ${email}: ${otp} (expires in 5 minutes)`);
|
||
} else {
|
||
// Fallback to console logging if email fails
|
||
console.log(`📧 OTP for ${email}: ${otp} (expires in 5 minutes) [EMAIL FALLBACK]`);
|
||
}
|
||
|
||
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({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 from PostgreSQL
|
||
const otpRows = await executeQuery(
|
||
'SELECT * FROM otps WHERE email = $1',
|
||
[email]
|
||
);
|
||
|
||
if (otpRows.length === 0) {
|
||
return res.status(400).json({ error: "Invalid verification code" });
|
||
}
|
||
|
||
const otpData = otpRows[0];
|
||
|
||
// Check if OTP is expired
|
||
if (Date.now() > otpData.expires_at) {
|
||
await executeQuery('DELETE FROM otps WHERE email = $1', [email]);
|
||
return res.status(400).json({ error: "Verification code has expired" });
|
||
}
|
||
|
||
// Check if too many attempts
|
||
if (otpData.attempts >= 5) {
|
||
await executeQuery('DELETE FROM otps WHERE email = $1', [email]);
|
||
return res.status(400).json({ error: "Too many failed attempts" });
|
||
}
|
||
|
||
// Verify OTP
|
||
if (otpData.otp !== otp) {
|
||
await executeQuery(
|
||
'UPDATE otps SET attempts = $1 WHERE email = $2',
|
||
[(otpData.attempts || 0) + 1, email]
|
||
);
|
||
return res.status(400).json({ error: "Invalid verification code" });
|
||
}
|
||
|
||
// OTP is valid - clean up and create/find user
|
||
await executeQuery('DELETE FROM otps WHERE email = $1', [email]);
|
||
|
||
// Generate a unique user ID for this email if it doesn't exist
|
||
let userId;
|
||
let userExists = false;
|
||
|
||
// Check if user already exists by email
|
||
const existingUserRows = await executeQuery(
|
||
'SELECT id FROM users WHERE email = $1 LIMIT 1',
|
||
[email]
|
||
);
|
||
|
||
if (existingUserRows.length > 0) {
|
||
// User exists, get their ID
|
||
userId = existingUserRows[0].id;
|
||
userExists = true;
|
||
} else {
|
||
// Create new user
|
||
userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
// Prepare user object for response
|
||
const user = {
|
||
uid: userId,
|
||
email: email,
|
||
displayName: email.split("@")[0],
|
||
emailVerified: true,
|
||
};
|
||
|
||
// Create or update user document in PostgreSQL
|
||
const currentTime = Date.now();
|
||
|
||
if (!userExists) {
|
||
// Create new user document
|
||
await executeQuery(
|
||
`INSERT INTO users (id, email, display_name, email_verified, created_at, last_login_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||
[userId, email, email.split("@")[0], true, currentTime, currentTime]
|
||
);
|
||
} else {
|
||
// Update existing user with latest login info
|
||
await executeQuery(
|
||
'UPDATE users SET last_login_at = $1 WHERE id = $2',
|
||
[currentTime, userId]
|
||
);
|
||
}
|
||
|
||
// Generate a simple session token (in production, use proper JWT)
|
||
const customToken = `session_${userId}_${Date.now()}`;
|
||
|
||
// Store auth token in PostgreSQL for validation
|
||
await executeQuery(
|
||
`INSERT INTO auth_tokens (token, user_id, created_at, expires_at, last_used_at, is_active)
|
||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||
[
|
||
customToken,
|
||
userId,
|
||
currentTime,
|
||
currentTime + (30 * 24 * 60 * 60 * 1000), // 30 days
|
||
currentTime,
|
||
true
|
||
]
|
||
);
|
||
|
||
// Handle invitation if present
|
||
let inviteData = null;
|
||
if (otpData.invite_code) {
|
||
try {
|
||
const inviteRows = await executeQuery(
|
||
'SELECT * FROM invites WHERE code = $1 AND status = $2 LIMIT 1',
|
||
[otpData.invite_code, 'pending']
|
||
);
|
||
|
||
if (inviteRows.length > 0) {
|
||
inviteData = inviteRows[0];
|
||
}
|
||
} 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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const { email } = req.body;
|
||
|
||
if (!email) {
|
||
return res.status(400).json({ error: "Email is 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,
|
||
email: email.trim(),
|
||
role: "employee",
|
||
department: "General",
|
||
status: "invited",
|
||
inviteCode: code
|
||
};
|
||
|
||
// Store invitation with employee data in PostgreSQL
|
||
await executeQuery(
|
||
`INSERT INTO invites (
|
||
code, employee_data, email, organization_id, status,
|
||
created_at, expires_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
ON CONFLICT (code) DO UPDATE SET
|
||
employee_data = EXCLUDED.employee_data,
|
||
email = EXCLUDED.email,
|
||
organization_id = EXCLUDED.organization_id,
|
||
status = EXCLUDED.status,
|
||
created_at = EXCLUDED.created_at,
|
||
expires_at = EXCLUDED.expires_at`,
|
||
[
|
||
code,
|
||
JSON.stringify(employee),
|
||
email,
|
||
orgId,
|
||
"pending",
|
||
Date.now(),
|
||
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}`;
|
||
|
||
// Send invitation email
|
||
const subject = 'You\'re invited to join Auditly';
|
||
const htmlContent = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<div style="text-align: center; margin-bottom: 30px;">
|
||
<h1 style="color: #2563eb; margin: 0;">Auditly</h1>
|
||
</div>
|
||
|
||
<div style="background-color: #f8fafc; padding: 30px; border-radius: 8px; margin-bottom: 20px;">
|
||
<h2 style="color: #1e293b; margin-top: 0;">You're Invited!</h2>
|
||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">
|
||
You've been invited to participate in your company's employee assessment on Auditly.
|
||
Your input will help improve workplace culture and team dynamics.
|
||
</p>
|
||
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="${inviteLink}"
|
||
style="background-color: #2563eb; color: white; text-decoration: none; padding: 15px 30px; border-radius: 6px; font-weight: bold; display: inline-block;">
|
||
Complete Your Assessment
|
||
</a>
|
||
</div>
|
||
|
||
<div style="background-color: #e2e8f0; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||
<p style="margin: 0; font-size: 14px; color: #64748b;">
|
||
<strong>Invitation Code:</strong> ${code}
|
||
</p>
|
||
</div>
|
||
|
||
<p style="color: #64748b; font-size: 14px;">
|
||
This invitation link will expire in 7 days. If you have any questions, please contact your HR department.
|
||
</p>
|
||
</div>
|
||
|
||
<div style="text-align: center; color: #64748b; font-size: 12px;">
|
||
<p>This is a confidential invitation. Please do not share this link.</p>
|
||
<p>© 2024 Auditly. All rights reserved.</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const emailSent = await sendEmail(email, subject, htmlContent);
|
||
|
||
if (emailSent) {
|
||
console.log(`📧 Invitation email sent to ${email} with code: ${code}`);
|
||
} else {
|
||
// Fallback to console logging if email fails
|
||
console.log(`📧 Invite link: ${inviteLink} [EMAIL FALLBACK]`);
|
||
}
|
||
|
||
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({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 inviteRows = await executeQuery(
|
||
'SELECT * FROM invites WHERE code = $1',
|
||
[code]
|
||
);
|
||
|
||
if (inviteRows.length === 0) {
|
||
return res.status(404).json({ error: "Invitation not found" });
|
||
}
|
||
|
||
const invite = inviteRows[0];
|
||
|
||
// Check if expired
|
||
if (Date.now() > invite.expires_at) {
|
||
return res.status(400).json({ error: "Invitation has expired" });
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
used: invite.status !== 'pending',
|
||
employee: invite.employee_data,
|
||
invite: {
|
||
code: invite.code,
|
||
email: invite.email,
|
||
orgId: invite.organization_id,
|
||
status: invite.status,
|
||
createdAt: invite.created_at,
|
||
expiresAt: invite.expires_at
|
||
},
|
||
});
|
||
} 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({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 inviteRows = await executeQuery(
|
||
'SELECT * FROM invites WHERE code = $1',
|
||
[code]
|
||
);
|
||
|
||
if (inviteRows.length === 0) {
|
||
return res.status(404).json({ error: "Invitation not found" });
|
||
}
|
||
|
||
const invite = inviteRows[0];
|
||
|
||
// Check if expired
|
||
if (Date.now() > invite.expires_at) {
|
||
return res.status(400).json({ error: "Invitation has expired" });
|
||
}
|
||
|
||
// Get employee data from the invite
|
||
const employee = JSON.parse(invite.employee_data);
|
||
if (!employee) {
|
||
return res.status(400).json({ error: "Invalid invitation data - missing employee information" });
|
||
}
|
||
|
||
// Mark invitation as consumed and add employee
|
||
await executeTransaction(async (client) => {
|
||
// Mark invitation as consumed
|
||
if (process.env.USE_NEON_SERVERLESS === 'true') {
|
||
await client(
|
||
`UPDATE invites SET
|
||
status = $1,
|
||
consumed_by = $2,
|
||
consumed_at = $3
|
||
WHERE code = $4`,
|
||
["consumed", employee.id, Date.now(), code]
|
||
);
|
||
|
||
// Add employee to organization
|
||
await client(
|
||
`INSERT INTO employees (
|
||
id, name, email, role, department,
|
||
organization_id, joined_at, status, invite_code
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
email = EXCLUDED.email,
|
||
role = EXCLUDED.role,
|
||
department = EXCLUDED.department,
|
||
status = EXCLUDED.status`,
|
||
[
|
||
employee.id,
|
||
employee.name || employee.email.split("@")[0],
|
||
employee.email,
|
||
employee.role || "employee",
|
||
employee.department || "General",
|
||
invite.organization_id,
|
||
Date.now(),
|
||
"active",
|
||
code
|
||
]
|
||
);
|
||
} else {
|
||
await client.query(
|
||
`UPDATE invites SET
|
||
status = $1,
|
||
consumed_by = $2,
|
||
consumed_at = $3
|
||
WHERE code = $4`,
|
||
["consumed", employee.id, Date.now(), code]
|
||
);
|
||
|
||
// Add employee to organization
|
||
await client.query(
|
||
`INSERT INTO employees (
|
||
id, name, email, role, department,
|
||
organization_id, joined_at, status, invite_code
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
email = EXCLUDED.email,
|
||
role = EXCLUDED.role,
|
||
department = EXCLUDED.department,
|
||
status = EXCLUDED.status`,
|
||
[
|
||
employee.id,
|
||
employee.name || employee.email.split("@")[0],
|
||
employee.email,
|
||
employee.role || "employee",
|
||
employee.department || "General",
|
||
invite.organization_id,
|
||
Date.now(),
|
||
"active",
|
||
code
|
||
]
|
||
);
|
||
}
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
orgId: invite.organization_id,
|
||
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({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 { 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 inviteRows = await executeQuery(
|
||
'SELECT * FROM invites WHERE code = $1',
|
||
[inviteCode]
|
||
);
|
||
|
||
if (inviteRows.length === 0) {
|
||
return res.status(404).json({ error: "Invitation not found" });
|
||
}
|
||
|
||
const invite = inviteRows[0];
|
||
|
||
// Check if expired
|
||
if (Date.now() > invite.expires_at) {
|
||
return res.status(400).json({ error: "Invitation has expired" });
|
||
}
|
||
|
||
const employee = invite.employee_data;
|
||
finalOrgId = invite.organization_id;
|
||
finalEmployeeId = employee.id;
|
||
|
||
employee.status = "active";
|
||
|
||
// Update invitation and employee status
|
||
await executeTransaction(async (client) => {
|
||
if (process.env.USE_NEON_SERVERLESS === 'true') {
|
||
await client(
|
||
`UPDATE invites SET
|
||
status = $1,
|
||
consumed_by = $2,
|
||
consumed_at = $3,
|
||
employee_data = $4
|
||
WHERE code = $5`,
|
||
["consumed", finalEmployeeId, Date.now(), JSON.stringify(employee), inviteCode]
|
||
);
|
||
|
||
// Add/update employee
|
||
await client(
|
||
`INSERT INTO employees (
|
||
id, name, email, role, department,
|
||
organization_id, joined_at, status, invite_code
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
email = EXCLUDED.email,
|
||
role = EXCLUDED.role,
|
||
department = EXCLUDED.department,
|
||
status = EXCLUDED.status`,
|
||
[
|
||
employee.id,
|
||
answers.name || employee.email.split("@")[0],
|
||
employee.email,
|
||
employee.role || "employee",
|
||
employee.department || "General",
|
||
finalOrgId,
|
||
Date.now(),
|
||
"active",
|
||
inviteCode
|
||
]
|
||
);
|
||
} else {
|
||
await client.query(
|
||
`UPDATE invites SET
|
||
status = $1,
|
||
consumed_by = $2,
|
||
consumed_at = $3,
|
||
employee_data = $4
|
||
WHERE code = $5`,
|
||
["consumed", finalEmployeeId, Date.now(), JSON.stringify(employee), inviteCode]
|
||
);
|
||
|
||
// Add/update employee
|
||
await client.query(
|
||
`INSERT INTO employees (
|
||
id, name, email, role, department,
|
||
organization_id, joined_at, status, invite_code
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
email = EXCLUDED.email,
|
||
role = EXCLUDED.role,
|
||
department = EXCLUDED.department,
|
||
status = EXCLUDED.status`,
|
||
[
|
||
employee.id,
|
||
answers.name || employee.email.split("@")[0],
|
||
employee.email,
|
||
employee.role || "employee",
|
||
employee.department || "General",
|
||
finalOrgId,
|
||
Date.now(),
|
||
"active",
|
||
inviteCode
|
||
]
|
||
);
|
||
}
|
||
});
|
||
} else {
|
||
// Authenticated submission
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (!answers) {
|
||
return res.status(400).json({ error: "Answers are required for authenticated submissions" });
|
||
}
|
||
|
||
finalOrgId = authContext.orgId;
|
||
|
||
if (!finalOrgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
}
|
||
|
||
// Store submission in PostgreSQL
|
||
await executeQuery(
|
||
`INSERT INTO submissions (
|
||
employee_id, organization_id, answers, submitted_at,
|
||
status, submission_type, invite_code
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
ON CONFLICT (employee_id, organization_id) DO UPDATE SET
|
||
answers = EXCLUDED.answers,
|
||
submitted_at = EXCLUDED.submitted_at,
|
||
status = EXCLUDED.status,
|
||
submission_type = EXCLUDED.submission_type,
|
||
invite_code = EXCLUDED.invite_code`,
|
||
[
|
||
finalEmployeeId,
|
||
finalOrgId,
|
||
JSON.stringify(answers),
|
||
Date.now(),
|
||
"completed",
|
||
inviteCode ? "invite" : "regular",
|
||
inviteCode || null
|
||
]
|
||
);
|
||
|
||
// Generate employee report automatically with company context
|
||
try {
|
||
// Get employee data
|
||
const employeeRows = await executeQuery(
|
||
'SELECT * FROM employees WHERE id = $1 AND organization_id = $2',
|
||
[finalEmployeeId, finalOrgId]
|
||
);
|
||
|
||
const employeeData = employeeRows.length > 0 ? employeeRows[0] : null;
|
||
|
||
// Get company onboarding data for LLM context
|
||
const orgRows = await executeQuery(
|
||
'SELECT * FROM organizations WHERE id = $1',
|
||
[finalOrgId]
|
||
);
|
||
|
||
const orgData = orgRows.length > 0 ? orgRows[0] : {};
|
||
|
||
// Prepare company context (onboarding data)
|
||
let companyContext = {
|
||
name: orgData.name,
|
||
};
|
||
if (orgData.onboarding_data) {
|
||
companyContext = {
|
||
...companyContext,
|
||
...orgData.onboarding_data
|
||
};
|
||
}
|
||
// 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 PostgreSQL using individual columns
|
||
await executeQuery(
|
||
`INSERT INTO employee_reports (
|
||
employee_id, organization_id, employee_name, role, email, summary,
|
||
company_context, role_and_output, insights, strengths, weaknesses,
|
||
opportunities, risks, recommendations, grading_overview, generated_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||
ON CONFLICT (employee_id) DO UPDATE SET
|
||
employee_name = EXCLUDED.employee_name,
|
||
role = EXCLUDED.role,
|
||
email = EXCLUDED.email,
|
||
summary = EXCLUDED.summary,
|
||
company_context = EXCLUDED.company_context,
|
||
role_and_output = EXCLUDED.role_and_output,
|
||
insights = EXCLUDED.insights,
|
||
strengths = EXCLUDED.strengths,
|
||
weaknesses = EXCLUDED.weaknesses,
|
||
opportunities = EXCLUDED.opportunities,
|
||
risks = EXCLUDED.risks,
|
||
recommendations = EXCLUDED.recommendations,
|
||
grading_overview = EXCLUDED.grading_overview,
|
||
generated_at = EXCLUDED.generated_at`,
|
||
[
|
||
finalEmployeeId,
|
||
finalOrgId,
|
||
employeeData?.name || employeeData?.email || 'Employee',
|
||
employeeData?.role || "Team Member",
|
||
employeeData?.email || 'Unknown',
|
||
report.summary || `Performance analysis for ${employeeData?.name || employeeData?.email || 'Employee'}`,
|
||
JSON.stringify(companyContext),
|
||
JSON.stringify(report.roleAndOutput || {}),
|
||
JSON.stringify(report.insights || {}),
|
||
report.strengths || [],
|
||
report.weaknesses || [],
|
||
JSON.stringify(report.opportunities || []),
|
||
report.risks || [],
|
||
report.recommendations || [],
|
||
JSON.stringify(report.gradingOverview || {}),
|
||
Date.now()
|
||
]
|
||
);
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
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 using individual database columns
|
||
await executeQuery(
|
||
`INSERT INTO company_reports (
|
||
organization_id, executive_summary, overview, strengths, weaknesses,
|
||
personnel_changes, immediate_hiring_needs, forward_operating_plan,
|
||
organizational_impact_summary, grading_breakdown, generated_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||
ON CONFLICT (organization_id, created_at) DO UPDATE SET
|
||
executive_summary = EXCLUDED.executive_summary,
|
||
overview = EXCLUDED.overview,
|
||
strengths = EXCLUDED.strengths,
|
||
weaknesses = EXCLUDED.weaknesses,
|
||
personnel_changes = EXCLUDED.personnel_changes,
|
||
immediate_hiring_needs = EXCLUDED.immediate_hiring_needs,
|
||
forward_operating_plan = EXCLUDED.forward_operating_plan,
|
||
organizational_impact_summary = EXCLUDED.organizational_impact_summary,
|
||
grading_breakdown = EXCLUDED.grading_breakdown,
|
||
generated_at = EXCLUDED.generated_at`,
|
||
[
|
||
orgId,
|
||
report.executiveSummary || '',
|
||
JSON.stringify(report.overview || {}),
|
||
report.strengths || [],
|
||
JSON.stringify(report.weaknesses || []),
|
||
JSON.stringify(report.personnelChanges || {}),
|
||
JSON.stringify(report.immediateHiringNeeds || []),
|
||
JSON.stringify(report.forwardOperatingPlan || []),
|
||
JSON.stringify(report.organizationalImpactSummary || []),
|
||
JSON.stringify(report.gradingBreakdown || []),
|
||
Date.now()
|
||
]
|
||
);
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
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 should not reference whatever the user has just said. Instead, be direct and find an answer to their question and, when applicable, advise them on what should be done next or ideas for topics of interest that could be explored.
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
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)}`;
|
||
const currentTime = Date.now();
|
||
|
||
await executeTransaction(async (client) => {
|
||
// Create comprehensive organization document
|
||
if (process.env.USE_NEON_SERVERLESS === 'true') {
|
||
await client(
|
||
`INSERT INTO organizations (
|
||
id, name, owner_id, onboarding_completed,
|
||
subscription_status, trial_end, employee_count, reports_generated,
|
||
allowed_employee_count, features_enabled, created_at, updated_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||
[
|
||
orgId, name, authContext.userId, false,
|
||
'trial', currentTime + (14 * 24 * 60 * 60 * 1000), // 14 day trial
|
||
0, 0, 50,
|
||
JSON.stringify({"aiReports": true, "chat": true, "analytics": true}),
|
||
currentTime, currentTime
|
||
]
|
||
);
|
||
|
||
// Add organization to user's organizations (for multi-org support)
|
||
await client(
|
||
`INSERT INTO user_organizations (user_id, organization_id, role, onboarding_completed, joined_at)
|
||
VALUES ($1, $2, $3, $4, $5)`,
|
||
[authContext.userId, orgId, 'owner', false, currentTime]
|
||
);
|
||
} else {
|
||
await client.query(
|
||
`INSERT INTO organizations (
|
||
id, name, owner_id, onboarding_completed,
|
||
subscription_status, trial_end, employee_count, reports_generated,
|
||
allowed_employee_count, features_enabled, created_at, updated_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||
[
|
||
orgId, name, authContext.userId, false,
|
||
'trial', currentTime + (14 * 24 * 60 * 60 * 1000), // 14 day trial
|
||
0, 0, 50,
|
||
JSON.stringify({"aiReports": true, "chat": true, "analytics": true}),
|
||
currentTime, currentTime
|
||
]
|
||
);
|
||
|
||
// Add organization to user's organizations (for multi-org support)
|
||
await client.query(
|
||
`INSERT INTO user_organizations (user_id, organization_id, role, onboarding_completed, joined_at)
|
||
VALUES ($1, $2, $3, $4, $5)`,
|
||
[authContext.userId, orgId, 'owner', false, currentTime]
|
||
);
|
||
}
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
orgId,
|
||
name,
|
||
role: "owner",
|
||
onboardingCompleted: false,
|
||
joinedAt: currentTime,
|
||
subscription: {
|
||
status: 'trial',
|
||
trialEnd: currentTime + (14 * 24 * 60 * 60 * 1000)
|
||
},
|
||
requiresSubscription: true,
|
||
});
|
||
} 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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
// Get user's organizations through user_organizations join table
|
||
const orgRows = await executeQuery(
|
||
`SELECT
|
||
o.id as orgId,
|
||
o.name,
|
||
uo.role,
|
||
uo.onboarding_completed as onboardingCompleted,
|
||
uo.joined_at as joinedAt,
|
||
o.subscription_status,
|
||
o.trial_end,
|
||
o.created_at,
|
||
o.updated_at
|
||
FROM organizations o
|
||
JOIN user_organizations uo ON o.id = uo.organization_id
|
||
WHERE uo.user_id = $1
|
||
ORDER BY uo.joined_at DESC`,
|
||
[authContext.userId]
|
||
);
|
||
|
||
const organizations = orgRows.map(row => ({
|
||
orgId: row.orgid,
|
||
name: row.name,
|
||
role: row.role,
|
||
onboardingCompleted: row.onboardingcompleted,
|
||
joinedAt: row.joinedat,
|
||
subscription: {
|
||
status: row.subscription_status,
|
||
trialEnd: row.trial_end
|
||
},
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at
|
||
}));
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const { inviteCode } = req.body;
|
||
|
||
if (!inviteCode) {
|
||
return res.status(400).json({ error: "Invite code is required" });
|
||
}
|
||
|
||
// Find the invitation
|
||
const inviteRows = await executeQuery(
|
||
'SELECT * FROM invites WHERE code = $1 AND status = $2',
|
||
[inviteCode, 'pending']
|
||
);
|
||
|
||
if (inviteRows.length === 0) {
|
||
return res.status(404).json({ error: "Invitation not found or already used" });
|
||
}
|
||
|
||
const invite = inviteRows[0];
|
||
|
||
// Check if expired
|
||
if (Date.now() > invite.expires_at) {
|
||
return res.status(400).json({ error: "Invitation has expired" });
|
||
}
|
||
|
||
const orgId = invite.organization_id;
|
||
|
||
// Get organization details
|
||
const orgRows = await executeQuery(
|
||
'SELECT * FROM organizations WHERE id = $1',
|
||
[orgId]
|
||
);
|
||
|
||
if (orgRows.length === 0) {
|
||
return res.status(404).json({ error: "Organization not found" });
|
||
}
|
||
|
||
const orgData = orgRows[0];
|
||
const employee = JSON.parse(invite.employee_data);
|
||
|
||
// Get user information
|
||
const userRows = await executeQuery(
|
||
'SELECT * FROM users WHERE id = $1',
|
||
[authContext.userId]
|
||
);
|
||
|
||
if (userRows.length === 0) {
|
||
console.error("User document not found:", authContext.userId);
|
||
return res.status(400).json({ error: "User not found" });
|
||
}
|
||
|
||
const userData = userRows[0];
|
||
|
||
await executeTransaction(async (client) => {
|
||
// Mark invitation as consumed
|
||
if (process.env.USE_NEON_SERVERLESS === 'true') {
|
||
await client(
|
||
`UPDATE invites SET
|
||
status = $1,
|
||
consumed_by = $2,
|
||
consumed_at = $3
|
||
WHERE code = $4`,
|
||
["consumed", authContext.userId, Date.now(), inviteCode]
|
||
);
|
||
|
||
// Add user to organization employees
|
||
await client(
|
||
`INSERT INTO employees (
|
||
id, email, name, role, organization_id,
|
||
joined_at, status
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
name = EXCLUDED.name,
|
||
role = EXCLUDED.role,
|
||
status = EXCLUDED.status`,
|
||
[
|
||
authContext.userId,
|
||
userData.email,
|
||
userData.display_name || userData.email.split("@")[0],
|
||
employee.role || "employee",
|
||
orgId,
|
||
Date.now(),
|
||
"active"
|
||
]
|
||
);
|
||
|
||
// Add organization to user's organizations
|
||
await client(
|
||
`INSERT INTO user_organizations (
|
||
user_id, organization_id, role,
|
||
onboarding_completed, joined_at
|
||
) VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (user_id, organization_id) DO UPDATE SET
|
||
role = EXCLUDED.role,
|
||
onboarding_completed = EXCLUDED.onboarding_completed,
|
||
joined_at = EXCLUDED.joined_at`,
|
||
[
|
||
authContext.userId,
|
||
orgId,
|
||
employee.role || "employee",
|
||
orgData.onboarding_completed || false,
|
||
Date.now()
|
||
]
|
||
);
|
||
} else {
|
||
await client.query(
|
||
`UPDATE invites SET
|
||
status = $1,
|
||
consumed_by = $2,
|
||
consumed_at = $3
|
||
WHERE code = $4`,
|
||
["consumed", authContext.userId, Date.now(), inviteCode]
|
||
);
|
||
|
||
await client.query(
|
||
`INSERT INTO employees (
|
||
id, email, name, role, organization_id,
|
||
joined_at, status
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
name = EXCLUDED.name,
|
||
role = EXCLUDED.role,
|
||
status = EXCLUDED.status`,
|
||
[
|
||
authContext.userId,
|
||
userData.email,
|
||
userData.display_name || userData.email.split("@")[0],
|
||
employee.role || "employee",
|
||
orgId,
|
||
Date.now(),
|
||
"active"
|
||
]
|
||
);
|
||
|
||
await client.query(
|
||
`INSERT INTO user_organizations (
|
||
user_id, organization_id, role,
|
||
onboarding_completed, joined_at
|
||
) VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (user_id, organization_id) DO UPDATE SET
|
||
role = EXCLUDED.role,
|
||
onboarding_completed = EXCLUDED.onboarding_completed,
|
||
joined_at = EXCLUDED.joined_at`,
|
||
[
|
||
authContext.userId,
|
||
orgId,
|
||
employee.role || "employee",
|
||
orgData.onboarding_completed || false,
|
||
Date.now()
|
||
]
|
||
);
|
||
}
|
||
});
|
||
|
||
// Update user document with latest login activity
|
||
await executeQuery(
|
||
'UPDATE users SET last_login_at = $1 WHERE id = $2',
|
||
[Date.now(), authContext.userId]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
orgId,
|
||
name: orgData.name,
|
||
role: employee.role || "employee",
|
||
onboardingCompleted: orgData.onboarding_completed || 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, res);
|
||
// 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, res);
|
||
|
||
// 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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get organization data
|
||
const orgRows = await executeQuery(
|
||
'SELECT * FROM organizations WHERE id = $1',
|
||
[orgId]
|
||
);
|
||
|
||
if (orgRows.length === 0) {
|
||
return res.status(404).json({ error: "Organization not found" });
|
||
}
|
||
|
||
const orgData = {
|
||
id: orgRows[0].id,
|
||
name: orgRows[0].name,
|
||
industry: orgRows[0].industry,
|
||
description: orgRows[0].description,
|
||
mission: orgRows[0].mission,
|
||
vision: orgRows[0].vision,
|
||
values: orgRows[0].values,
|
||
onboardingCompleted: orgRows[0].onboarding_completed,
|
||
onboardingData: orgRows[0].onboarding_data,
|
||
createdAt: orgRows[0].created_at,
|
||
updatedAt: orgRows[0].updated_at
|
||
};
|
||
|
||
res.json({
|
||
success: true,
|
||
org: orgData
|
||
});
|
||
} catch (error) {
|
||
console.error("Get org data error:", error);
|
||
res.status(500).json({ error: "Failed to get organization data" });
|
||
}
|
||
});
|
||
//endregion Get Org Data
|
||
|
||
//region Update Organization Data
|
||
exports.updateOrgData = onRequest({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "PUT") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
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" });
|
||
}
|
||
|
||
// Build update query dynamically based on provided data fields
|
||
const allowedFields = [
|
||
'name', 'industry', 'description', 'mission', 'vision', 'values',
|
||
'onboarding_completed', 'onboarding_data'
|
||
];
|
||
|
||
const updateFields = [];
|
||
const values = [];
|
||
let paramCount = 1;
|
||
|
||
Object.keys(data).forEach(key => {
|
||
const dbKey = key === 'onboardingCompleted' ? 'onboarding_completed' :
|
||
key === 'onboardingData' ? 'onboarding_data' : key;
|
||
|
||
if (allowedFields.includes(dbKey)) {
|
||
updateFields.push(`${dbKey} = $${paramCount}`);
|
||
values.push(typeof data[key] === 'object' ? JSON.stringify(data[key]) : data[key]);
|
||
paramCount++;
|
||
}
|
||
});
|
||
|
||
if (updateFields.length === 0) {
|
||
return res.status(400).json({ error: "No valid fields to update" });
|
||
}
|
||
|
||
updateFields.push(`updated_at = $${paramCount}`);
|
||
values.push(Date.now());
|
||
values.push(orgId); // orgId for WHERE clause
|
||
|
||
const query = `UPDATE organizations SET ${updateFields.join(', ')} WHERE id = $${paramCount + 1}`;
|
||
|
||
await executeQuery(query, values);
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all employees (excluding owners)
|
||
const employeeRows = await executeQuery(
|
||
`SELECT * FROM employees
|
||
WHERE organization_id = $1
|
||
AND role != 'owner'
|
||
ORDER BY joined_at DESC`,
|
||
[orgId]
|
||
);
|
||
|
||
const employees = employeeRows.map(emp => ({
|
||
id: emp.id,
|
||
name: emp.name,
|
||
email: emp.email,
|
||
role: emp.role,
|
||
department: emp.department,
|
||
status: emp.status,
|
||
joinedAt: emp.joined_at,
|
||
inviteCode: emp.invite_code
|
||
}));
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all submissions
|
||
const submissionRows = await executeQuery(
|
||
'SELECT * FROM submissions WHERE organization_id = $1',
|
||
[orgId]
|
||
);
|
||
|
||
const submissions = {};
|
||
submissionRows.forEach(sub => {
|
||
submissions[sub.employee_id] = {
|
||
id: sub.employee_id,
|
||
employeeId: sub.employee_id,
|
||
answers: sub.answers,
|
||
submittedAt: sub.submitted_at,
|
||
status: sub.status,
|
||
submissionType: sub.submission_type,
|
||
inviteCode: sub.invite_code
|
||
};
|
||
});
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all reports
|
||
const reportRows = await executeQuery(
|
||
'SELECT * FROM employee_reports WHERE organization_id = $1',
|
||
[orgId]
|
||
);
|
||
|
||
const reports = {};
|
||
reportRows.forEach(report => {
|
||
reports[report.employee_id] = {
|
||
id: report.employee_id,
|
||
employeeId: report.employee_id,
|
||
employeeName: report.employee_name,
|
||
role: report.role,
|
||
email: report.email,
|
||
generatedAt: report.generated_at,
|
||
summary: report.summary,
|
||
companyContext: report.company_context,
|
||
roleAndOutput: report.role_and_output,
|
||
insights: report.insights,
|
||
strengths: report.strengths,
|
||
weaknesses: report.weaknesses,
|
||
opportunities: report.opportunities,
|
||
risks: report.risks,
|
||
recommendations: report.recommendations,
|
||
gradingOverview: report.grading_overview
|
||
};
|
||
});
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
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 {
|
||
// 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;
|
||
|
||
// Get employee info
|
||
const employeeRows = await executeQuery(
|
||
'SELECT name, email, role FROM employees WHERE id = $1 AND organization_id = $2',
|
||
[employeeId, orgId]
|
||
);
|
||
|
||
const employee = employeeRows.length > 0 ? employeeRows[0] : null;
|
||
|
||
// Save report using individual database columns
|
||
await executeQuery(
|
||
`INSERT INTO employee_reports (
|
||
employee_id, organization_id, employee_name, role, email, summary,
|
||
company_context, role_and_output, insights, strengths, weaknesses,
|
||
opportunities, risks, recommendations, grading_overview, generated_at
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||
ON CONFLICT (employee_id) DO UPDATE SET
|
||
employee_name = EXCLUDED.employee_name,
|
||
role = EXCLUDED.role,
|
||
email = EXCLUDED.email,
|
||
summary = EXCLUDED.summary,
|
||
company_context = EXCLUDED.company_context,
|
||
role_and_output = EXCLUDED.role_and_output,
|
||
insights = EXCLUDED.insights,
|
||
strengths = EXCLUDED.strengths,
|
||
weaknesses = EXCLUDED.weaknesses,
|
||
opportunities = EXCLUDED.opportunities,
|
||
risks = EXCLUDED.risks,
|
||
recommendations = EXCLUDED.recommendations,
|
||
grading_overview = EXCLUDED.grading_overview,
|
||
generated_at = EXCLUDED.generated_at`,
|
||
[
|
||
employeeId,
|
||
orgId,
|
||
employee?.name || 'Unknown',
|
||
employee?.role || 'Employee',
|
||
employee?.email || 'Unknown',
|
||
reportData.summary || `Performance analysis for ${employee?.name || employee?.email || 'Employee'}`,
|
||
JSON.stringify(reportData.companyContext || {}),
|
||
JSON.stringify(reportData.roleAndOutput || {}),
|
||
JSON.stringify(reportData.insights || {}),
|
||
reportData.strengths || [],
|
||
reportData.weaknesses || [],
|
||
JSON.stringify(reportData.opportunities || []),
|
||
reportData.risks || [],
|
||
reportData.recommendations || [],
|
||
JSON.stringify(reportData.gradingOverview || reportData.grading || {}),
|
||
currentTime
|
||
]
|
||
);
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "GET") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
try {
|
||
const orgId = authContext.orgId;
|
||
if (!orgId) {
|
||
return res.status(400).json({ error: "User has no associated organizations" });
|
||
}
|
||
|
||
// Get all company reports
|
||
const reportRows = await executeQuery(
|
||
'SELECT * FROM company_reports WHERE organization_id = $1 ORDER BY generated_at DESC',
|
||
[orgId]
|
||
);
|
||
|
||
const reports = reportRows.map(row => ({
|
||
id: `report_${row.generated_at}`,
|
||
generatedAt: row.generated_at,
|
||
executiveSummary: row.executive_summary,
|
||
overview: row.overview,
|
||
strengths: row.strengths,
|
||
weaknesses: row.weaknesses,
|
||
personnelChanges: row.personnel_changes,
|
||
immediateHiringNeeds: row.immediate_hiring_needs,
|
||
forwardOperatingPlan: row.forward_operating_plan,
|
||
organizationalImpactSummary: row.organizational_impact_summary,
|
||
gradingBreakdown: row.grading_breakdown
|
||
}));
|
||
|
||
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({cors: true}, async (req, res) => {
|
||
const authContext = await validateAuthAndGetContext(req, res);
|
||
if (!authContext) {
|
||
return;
|
||
}
|
||
|
||
if (req.method !== "POST") {
|
||
return res.status(405).json({ error: "Method not allowed" });
|
||
}
|
||
|
||
const { orgId, userId } = authContext;
|
||
const { imageData } = req.body;
|
||
|
||
if (!orgId || !userId || !imageData) {
|
||
return res.status(400).json({ error: "Organization ID, user ID, and image data are required" });
|
||
}
|
||
|
||
try {
|
||
// 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 imageId = `${Date.now()}_${filename}`;
|
||
const imageDoc = {
|
||
id: imageId,
|
||
dataUrl,
|
||
filename,
|
||
originalSize: originalSize || 0,
|
||
compressedSize: compressedSize || 0,
|
||
uploadedAt: Date.now(),
|
||
width: width || 0,
|
||
height: height || 0,
|
||
orgId,
|
||
uploadedBy: userId,
|
||
collectionName,
|
||
documentId
|
||
};
|
||
|
||
// Store image in organization's images table
|
||
await executeQuery(
|
||
`INSERT INTO organization_images (
|
||
id, organization_id, collection_name, document_id,
|
||
data_url, filename, original_size, compressed_size,
|
||
width, height, uploaded_at, uploaded_by
|
||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||
ON CONFLICT (organization_id, collection_name, document_id) DO UPDATE SET
|
||
id = EXCLUDED.id,
|
||
data_url = EXCLUDED.data_url,
|
||
filename = EXCLUDED.filename,
|
||
original_size = EXCLUDED.original_size,
|
||
compressed_size = EXCLUDED.compressed_size,
|
||
width = EXCLUDED.width,
|
||
height = EXCLUDED.height,
|
||
uploaded_at = EXCLUDED.uploaded_at,
|
||
uploaded_by = EXCLUDED.uploaded_by`,
|
||
[
|
||
imageId,
|
||
orgId,
|
||
collectionName,
|
||
documentId,
|
||
dataUrl,
|
||
filename,
|
||
originalSize || 0,
|
||
compressedSize || 0,
|
||
width || 0,
|
||
height || 0,
|
||
Date.now(),
|
||
userId
|
||
]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
imageId: imageId,
|
||
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
|