const express = require("express");
const cors = require("cors");
const multer = require("multer");
const fs = require("fs");
const path = require("path");
require("dotenv").config();
const auth = require("./middleware/auth");
const {
runAdminQuery,
isSupabaseAdminConfigured,
tables: supabaseTables,
normalizeGuideRow,
} = require("./lib/supabaseAdmin");
const { createClient } = require("@supabase/supabase-js");
// Create app immediately for fast health checks
const app = express();
// Super fast health check - responds immediately
app.get("/health", (req, res) => {
res.status(200).json({
status: "ok",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || "development",
vercel: !!process.env.VERCEL,
});
});
// Even simpler test endpoint
app.get("/test", (req, res) => {
res.status(200).json({ message: "Hello from Vercel!" });
});
// Diagnostic endpoint to check system status
app.get("/api/diagnostics", async (req, res) => {
let dbStatus = "not_configured";
let dbError = null;
try {
const { sequelize } = require("./database/connection");
if (sequelize) {
await sequelize.authenticate();
dbStatus = "connected";
} else {
dbStatus = "sequelize_null";
}
} catch (error) {
dbStatus = "error";
dbError = error.message;
}
// Check Supabase configuration
const supabaseConfigured = isSupabaseAdminConfigured();
const supabaseStatus = supabaseConfigured ? "configured" : "not_configured";
const supabaseMissing = [];
if (!process.env.SUPABASE_URL) supabaseMissing.push("SUPABASE_URL");
if (!process.env.SUPABASE_SERVICE_KEY)
supabaseMissing.push("SUPABASE_SERVICE_KEY");
// Determine if guide saving will work
const canSaveGuides = dbStatus === "connected" || supabaseConfigured;
res.json({
status: "running",
timestamp: new Date().toISOString(),
environment: {
NODE_ENV: process.env.NODE_ENV,
VERCEL: !!process.env.VERCEL,
DATABASE_URL: !!process.env.DATABASE_URL,
SUPABASE_URL: !!process.env.SUPABASE_URL,
SUPABASE_SERVICE_KEY: !!process.env.SUPABASE_SERVICE_KEY,
ANTHROPIC_API_KEY: !!process.env.ANTHROPIC_API_KEY,
STRIPE_SECRET_KEY: !!process.env.STRIPE_SECRET_KEY,
JWT_SECRET: !!process.env.JWT_SECRET,
RESEND_API_KEY: !!process.env.RESEND_API_KEY,
EMAIL_FROM: process.env.EMAIL_FROM || "not set",
},
database: {
status: dbStatus,
error: dbError,
},
supabase: {
status: supabaseStatus,
missing: supabaseMissing.length > 0 ? supabaseMissing : null,
guidesTable: supabaseTables.guides,
},
guideSaving: {
enabled: canSaveGuides,
method:
dbStatus === "connected"
? "sequelize"
: supabaseConfigured
? "supabase"
: "none",
warning: !canSaveGuides
? "Guide saving will FAIL! Configure DATABASE_URL or SUPABASE_SERVICE_KEY"
: null,
},
endpoints: {
health: "✅ Available",
test: "✅ Available",
guidesGenerate: "✅ Available (POST /api/guides/generate)",
diagnostics: "✅ Available (GET /api/diagnostics)",
},
});
});
// Root route handler
app.get("/", (req, res) => {
res.json({
message: "PREP101 Backend API",
status: "running",
version: "1.0.0",
endpoints: {
health: "/health",
apiHealth: "/api/health",
upload: "/api/upload",
guides: "/api/guides",
auth: "/api/auth",
},
documentation: "https://github.com/cor9/prep101-backend",
});
});
// Favicon route - serve favicon if requested
app.get("/favicon.ico", (req, res) => {
const faviconPath = path.join(__dirname, "client", "public", "favicon.ico");
if (fs.existsSync(faviconPath)) {
res.sendFile(faviconPath);
} else {
// Fallback: return 204 No Content if favicon doesn't exist
res.status(204).end();
}
});
// Trust proxy - Required for Vercel and rate limiting to work correctly
app.set("trust proxy", true);
// Basic middleware setup first
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// CORS - Allow specific origins including prep101.site
app.use(
cors({
origin: [
"https://prep101.site",
"https://prep101-api.vercel.app",
"http://localhost:3000",
"http://localhost:3001",
],
credentials: true,
optionsSuccessStatus: 200,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
})
);
// Continue with other imports
const pdfParse = require("pdf-parse");
// Try to load Adobe extractor, but don't fail if it's not available
let extractWithAdobe;
try {
extractWithAdobe =
require("./services/extractors/adobeExtract").extractWithAdobe;
} catch (error) {
console.log("⚠️ Adobe extractor not available, using basic extraction only");
extractWithAdobe = null;
}
const {
DEFAULT_CLAUDE_MODEL,
DEFAULT_CLAUDE_MAX_TOKENS,
} = require("./config/models");
// Import new authentication and payment features (with error handling)
let config,
validateConfig,
authLimiter,
apiLimiter,
paymentLimiter,
speedLimiter,
corsOptions,
securityHeaders;
try {
const configModule = require("./config/config");
config = configModule.config;
validateConfig = configModule.validateConfig;
} catch (error) {
console.log("⚠️ Config module not available:", error.message);
config = { jwt: { secret: "fallback" } };
validateConfig = () => console.log("⚠️ Config validation skipped");
}
try {
const securityModule = require("./middleware/security");
authLimiter = securityModule.authLimiter;
apiLimiter = securityModule.apiLimiter;
paymentLimiter = securityModule.paymentLimiter;
speedLimiter = securityModule.speedLimiter;
corsOptions = securityModule.corsOptions;
securityHeaders = securityModule.securityHeaders;
} catch (error) {
console.log("⚠️ Security middleware not available:", error.message);
// Create fallback middleware
authLimiter = (req, res, next) => next();
apiLimiter = (req, res, next) => next();
paymentLimiter = (req, res, next) => next();
speedLimiter = (req, res, next) => next();
securityHeaders = (req, res, next) => next();
}
// Validate configuration (skip in Vercel if env vars not set)
if (process.env.VERCEL) {
console.log("🚀 Running in Vercel serverless environment");
console.log(
"⚠️ Skipping config validation - environment variables will be set in Vercel dashboard"
);
} else {
validateConfig();
}
// Security middleware
app.use(securityHeaders);
// Rate limiting
app.use("/api/auth", authLimiter);
app.use("/api/payments", paymentLimiter);
app.use("/api", apiLimiter);
app.use(speedLimiter);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
const uploads = {};
// Track extraction diagnostics for /api/health
const extractionStats = {
totals: { adobe: 0, basic: 0, ocr: 0 },
last: null,
};
// Enhanced text cleaning function for basic extraction
function cleanBasicText(text) {
if (!text) return "";
return (
text
.replace(/\r/g, "")
.replace(/Sides by Breakdown Services - Actors Access/gi, "")
.replace(/Page \d+ of \d+/gi, "")
// Enhanced cleaning patterns
.replace(/\b\d{5,}\b/g, "") // Remove numeric watermarks
.replace(/^\d{1,2}:\d{2}:\d{2}\s*$/gm, "") // Remove timestamp lines
.replace(/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s*$/gm, "") // Remove date-time lines
.replace(/^[0-9\s\-_:]+$/gm, "") // Remove lines with only numbers/symbols
.replace(/^[A-Za-z]{1,2}\s*$/gm, "") // Remove single/double letter lines
.replace(/\n{3,}/g, "\n\n")
.trim()
);
}
// Content quality assessment function
// NOTE: Very lenient on upload, stricter on generation
function assessContentQuality(text, wordCount, isUpload = false) {
// UPLOAD CHECK: Only reject completely empty/corrupted files
if (isUpload) {
if (!text || wordCount < 10) {
return { quality: "poor", reason: "insufficient_content" };
}
// For uploads, accept everything else and let generation handle quality
return { quality: "good", reason: "sufficient_content" };
}
// GENERATION CHECK: Stricter validation before spending API tokens
if (!text || wordCount < 25) {
return { quality: "poor", reason: "insufficient_content" };
}
// Check for repetitive content patterns
const repetitivePatterns = [
/\b\d{5,}\b/g, // Numeric watermarks
/^\d{1,2}:\d{2}:\d{2}\s*$/gm, // Timestamps
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s*$/gm, // Date-time stamps
/- Aug \d{1,2}, \d{4} \d{1,2}:\d{2} (AM|PM) -/g, // Specific timestamp pattern from logs
/^\d{1,2}:\d{2} (AM|PM) -/gm, // Time AM/PM pattern
];
// Check for repetitive content
const repetitiveMatches = repetitivePatterns.reduce((count, pattern) => {
return count + (text.match(pattern) || []).length;
}, 0);
const repetitiveRatio = repetitiveMatches / Math.max(wordCount, 1);
// Check for high repetition of the same phrases
const words = text.toLowerCase().split(/\s+/);
const wordFreq = {};
words.forEach((word) => {
if (word.length > 3) {
// Only count words longer than 3 chars
wordFreq[word] = (wordFreq[word] || 0) + 1;
}
});
const maxFreq = Math.max(...Object.values(wordFreq));
const repetitionRatio = maxFreq / Math.max(wordCount, 1);
// Lenient criteria - only reject truly corrupted content
if (repetitiveRatio > 0.5) {
// More than 50% repetitive patterns
return { quality: "poor", reason: "repetitive_content", repetitiveRatio };
}
if (repetitionRatio > 0.5) {
// More than 50% of content is the same word
return { quality: "poor", reason: "high_repetition", repetitionRatio };
}
if (wordCount < 100) {
return { quality: "low", reason: "minimal_content" };
}
return { quality: "good", reason: "sufficient_content" };
}
// Basic extraction helper used as fallback
async function extractWithBasic(pdfBuffer) {
const data = await pdfParse(pdfBuffer);
let text = cleanBasicText(data.text || "");
const wordCount = (text.match(/\b\w+\b/g) || []).length;
const confidence =
wordCount > 600 ? "high" : wordCount > 300 ? "medium" : "low";
// Character names in ALL-CAPS ending with colon
const characterPattern = /^[A-Z][A-Z\s]+:/gm;
const characterNames = [
...new Set(
(text.match(characterPattern) || []).map((n) => n.replace(":", "").trim())
),
];
return { text, method: "basic", wordCount, confidence, characterNames };
}
// OCR extraction using Claude's vision model as fallback
async function extractWithOCR(pdfBuffer) {
try {
console.log("[OCR] Starting OCR extraction with Claude Vision...");
// Convert PDF to images first
const pdf2pic = require("pdf2pic");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Create temporary directory for images
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-ocr-"));
const tempPdfPath = path.join(tempDir, "input.pdf");
fs.writeFileSync(tempPdfPath, pdfBuffer);
// Convert PDF to images
const options = {
density: 300,
saveFilename: "page",
savePath: tempDir,
format: "png",
width: 2048,
height: 2048,
};
const convert = pdf2pic.fromPath(tempPdfPath, options);
// Try to get page count by attempting to convert pages until it fails
let pageCount = 0;
let maxPages = 10; // Reasonable limit for sides
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
try {
const testPage = await convert(pageNum);
if (testPage && testPage.path) {
pageCount = pageNum;
} else {
break;
}
} catch (error) {
break;
}
}
console.log(`[OCR] PDF has ${pageCount} pages`);
if (pageCount === 0) {
throw new Error("Failed to detect any pages in PDF");
}
let allText = "";
// Process each page
for (let pageNum = 1; pageNum <= pageCount; pageNum++) {
console.log(`[OCR] Processing page ${pageNum}/${pageCount}...`);
const pageData = await convert(pageNum);
if (!pageData || !pageData.path) {
console.log(`[OCR] Failed to convert page ${pageNum}, skipping...`);
continue;
}
// Read the image file
const imageBuffer = fs.readFileSync(pageData.path);
const base64Image = imageBuffer.toString("base64");
// Prepare the message for Claude Vision
const message = {
role: "user",
content: [
{
type: "text",
text: `Please extract all the text from this script/sides image (page ${pageNum} of ${pageCount.length}). Focus on:
1. Character names (in ALL CAPS followed by colon)
2. Dialogue and scene descriptions
3. Stage directions and parentheticals
4. Scene headings and transitions
Ignore watermarks, timestamps, page numbers, and other metadata. Return only the clean script content.`,
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: base64Image,
},
},
],
};
// Call Claude Vision API for this page
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-3-5-sonnet-20241022",
max_tokens: DEFAULT_CLAUDE_MAX_TOKENS,
messages: [message],
}),
});
if (!response.ok) {
const errorText = await response.text();
console.log(`[OCR] Failed to process page ${pageNum}: ${errorText}`);
continue;
}
const result = await response.json();
const pageText = result.content[0].text || "";
// Add page text to total (with separator)
if (pageText.trim()) {
allText += (allText ? "\n\n" : "") + pageText.trim();
}
}
// Clean up temporary files
fs.rmSync(tempDir, { recursive: true, force: true });
// Clean the extracted text from all pages
let text = cleanBasicText(allText);
const wordCount = (text.match(/\b\w+\b/g) || []).length;
const confidence =
wordCount > 600 ? "high" : wordCount > 300 ? "medium" : "low";
// Character names in ALL-CAPS ending with colon
const characterPattern = /^[A-Z][A-Z\s]+:/gm;
const characterNames = [
...new Set(
(text.match(characterPattern) || []).map((n) =>
n.replace(":", "").trim()
)
),
];
console.log(
`[OCR] Extraction completed: ${wordCount} words, ${characterNames.length} characters found`
);
return { text, method: "ocr", wordCount, confidence, characterNames };
} catch (error) {
console.error("[OCR] Extraction failed:", error.message);
throw new Error(`OCR extraction failed: ${error.message}`);
}
}
// Import and mount new API routes (with error handling)
try {
const authRoutes = require("./routes/auth");
app.use("/api/auth", authRoutes);
console.log("✅ Auth routes loaded");
} catch (error) {
console.log("⚠️ Auth routes not available:", error.message);
// Add fallback routes for when auth routes fail to load
app.get("/api/auth/dashboard", (req, res) => {
res.status(503).json({
error: "Authentication service temporarily unavailable",
message: "Database connection required for authentication features",
});
});
app.get("/api/auth/profile", (req, res) => {
res.status(503).json({
error: "Authentication service temporarily unavailable",
message: "Database connection required for authentication features",
});
});
// Add fallback login route - use Supabase directly
app.post("/api/auth/login", async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email & password required" });
}
// Create Supabase client for login if not available
const SUPABASE_URL = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
if (SUPABASE_URL && SUPABASE_ANON_KEY) {
try {
const supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
const { data, error } = await supabaseClient.auth.signInWithPassword({
email,
password,
});
if (error) {
return res.status(401).json({ message: error.message || "Invalid credentials" });
}
if (data?.user && data?.session) {
// Try to find or create user in database if User model is available
let user = null;
if (User) {
const userEmail = data.user.email.toLowerCase();
user = await User.findOne({ where: { email: userEmail } });
if (!user) {
const crypto = require("crypto");
const randomPassword = crypto.randomBytes(32).toString("hex");
const derivedName = data.user.user_metadata?.name || email.split("@")[0];
const betaAccessLevel = data.user.user_metadata?.betaAccessLevel || data.user.app_metadata?.betaAccessLevel || "none";
const isBetaTester = betaAccessLevel !== "none";
user = await User.create({
email: userEmail,
password: randomPassword,
name: derivedName,
subscription: "free",
guidesLimit: 1,
isBetaTester,
betaAccessLevel,
});
}
}
return res.json({
message: "Login successful",
token: data.session.access_token,
user: {
id: user?.id || data.user.id,
email: user?.email || data.user.email,
name: user?.name || data.user.user_metadata?.name || email.split("@")[0],
},
});
}
} catch (supabaseError) {
console.error("Supabase login error:", supabaseError);
}
}
// Fallback: try database login if User model is available
if (User) {
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ message: "Invalid credentials" });
}
const bcrypt = require("bcryptjs");
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: "Invalid credentials" });
}
const jwt = require("jsonwebtoken");
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET || "fallback_secret",
{ expiresIn: "24h" }
);
return res.json({
message: "Login successful",
token,
user: { id: user.id, name: user.name, email: user.email },
});
}
return res.status(503).json({
message: "Authentication service unavailable",
error: "No authentication method available. Please configure SUPABASE_URL and SUPABASE_ANON_KEY.",
});
} catch (err) {
console.error("Login error:", err);
return res.status(500).json({ message: "Login error", error: err.message });
}
});
}
try {
const paymentRoutes = require("./routes/payments");
app.use("/api/payments", paymentRoutes);
console.log("✅ Payment routes loaded");
} catch (error) {
console.log("⚠️ Payment routes not available:", error.message);
// Add fallback routes for when payment routes fail to load
app.get("/api/payments/*", (req, res) => {
res.status(503).json({
error: "Payment service temporarily unavailable",
message: "Database connection required for payment features",
});
});
}
try {
const guidesRoutes = require("./routes/guides");
app.use("/api/guides", guidesRoutes);
console.log("✅ Guide routes loaded");
} catch (error) {
console.log("⚠️ Guide routes not available:", error.message);
}
try {
const adminRoutes = require("./routes/admin");
app.use("/api/admin", adminRoutes);
console.log("✅ Admin routes loaded");
} catch (error) {
console.log("⚠️ Admin routes not available:", error.message);
}
try {
const betaRoutes = require("./routes/beta");
app.use("/api/beta", betaRoutes);
console.log("✅ Beta routes loaded");
} catch (error) {
console.log("⚠️ Beta routes not available:", error.message);
}
try {
const promoCodeRoutes = require("./routes/promoCodes");
app.use("/api/promo-codes", promoCodeRoutes);
console.log("✅ Promo code routes loaded");
} catch (error) {
console.log("⚠️ Promo code routes not available:", error.message);
}
try {
const stripeRoutes = require("./routes/stripe");
app.use("/api/stripe", stripeRoutes);
console.log("✅ Stripe routes loaded");
} catch (error) {
console.log("⚠️ Stripe routes not available:", error.message);
// Add fallback routes for when Stripe routes fail to load
app.get("/api/stripe/*", (req, res) => {
res.status(503).json({
error: "Stripe service temporarily unavailable",
message: "Database connection required for Stripe features",
});
});
}
try {
const stripeWebhookRoutes = require("./routes/stripeWebhook");
app.use("/api/webhooks", stripeWebhookRoutes);
console.log("✅ Stripe webhook routes loaded");
} catch (error) {
console.log("⚠️ Stripe webhook routes not available:", error.message);
}
// Secure API key handling (trim to avoid invisible whitespace issues)
const ANTHROPIC_API_KEY = (process.env.ANTHROPIC_API_KEY || "").trim();
if (!ANTHROPIC_API_KEY) {
console.error("❌ ANTHROPIC_API_KEY not found in environment variables");
if (process.env.VERCEL) {
console.log(
"⚠️ Anthropic API key missing in Vercel - guide generation will fail"
);
} else {
process.exit(1);
}
}
// Debug environment variables
console.log("🔧 Environment variables loaded:");
console.log(" - JWT_SECRET present:", !!process.env.JWT_SECRET);
// Masked Anthropic key diagnostics (length only)
try {
const masked = ANTHROPIC_API_KEY
? `len=${ANTHROPIC_API_KEY.length}`
: "missing";
console.log(" - ANTHROPIC_API_KEY:", masked);
} catch (_) {}
console.log(" - FRONTEND_URL:", process.env.FRONTEND_URL);
console.log(" - API_BASE:", process.env.API_BASE);
// Database initialization (with error handling)
let sequelize, testConnection, User, Guide;
try {
const dbModule = require("./database/connection");
sequelize = dbModule.sequelize;
testConnection = dbModule.testConnection;
console.log("✅ Database connection module loaded");
} catch (error) {
console.log("⚠️ Database connection module not available:", error.message);
sequelize = null;
testConnection = () => Promise.reject(new Error("Database not available"));
}
try {
User = require("./models/User");
if (User) {
console.log("✅ User model loaded successfully");
} else {
console.log("⚠️ User model is null - sequelize connection likely failed");
console.log(" Check DATABASE_URL and database connection logs above");
}
} catch (error) {
console.log("⚠️ User model not available:", error.message);
User = null;
}
try {
Guide = require("./models/Guide");
if (Guide) {
console.log("✅ Guide model loaded successfully");
} else {
console.log("⚠️ Guide model is null - sequelize connection likely failed");
console.log(" Check DATABASE_URL and database connection logs above");
}
} catch (error) {
console.log("⚠️ Guide model not available:", error.message);
Guide = null;
}
const SUPABASE_GUIDES_TABLE = supabaseTables.guides;
async function supabaseFetchGuide(filters = {}) {
if (!isSupabaseAdminConfigured()) return null;
const result = await runAdminQuery((client) => {
let query = client.from(SUPABASE_GUIDES_TABLE).select("*");
if (filters.id) query = query.eq("id", filters.id);
if (filters.guideId) query = query.eq("guideId", filters.guideId);
if (filters.userId) query = query.eq("userId", filters.userId);
return query.maybeSingle();
});
if (!result) return null;
if (result.error) {
console.error("❌ Supabase fetch guide error:", result.error.message);
return null;
}
return normalizeGuideRow(result.data);
}
async function supabaseInsertGuide(payload, options = {}) {
if (!isSupabaseAdminConfigured()) {
console.error(
"❌ Supabase admin client not configured - SUPABASE_URL or SUPABASE_SERVICE_KEY missing"
);
return null;
}
const { user, retryAttempt = 0 } = options;
console.log("📝 Attempting Supabase guide insert...", {
guideId: payload.guideId,
userId: payload.userId,
characterName: payload.characterName,
retryAttempt,
});
const now = new Date().toISOString();
const guidePayload = {
...payload,
createdAt: payload.createdAt || now,
updatedAt: payload.updatedAt || now,
};
const result = await runAdminQuery((client) =>
client.from(SUPABASE_GUIDES_TABLE).insert(guidePayload).select("*").single()
);
if (!result) {
console.error(
"❌ Supabase runAdminQuery returned null - client unavailable"
);
return null;
}
if (result.error) {
const errorMessage = result.error.message || "";
if (
user &&
retryAttempt < 1 &&
errorMessage.includes("Guides_userId_fkey")
) {
console.warn(
"⚠️ Supabase guide insert failed due to missing user reference. Retrying after ensuring user exists.",
{ userId: user.id }
);
const ensured = await ensureSupabaseUser(user);
if (ensured) {
return await supabaseInsertGuide(payload, {
...options,
retryAttempt: retryAttempt + 1,
});
}
}
console.error("❌ Supabase insert error:", result.error);
throw new Error(
result.error.message || "Failed to save guide via Supabase"
);
}
console.log("✅ Supabase guide insert successful:", result.data?.id);
return normalizeGuideRow(result.data);
}
async function supabaseUpdateGuide(id, userId, updates) {
if (!isSupabaseAdminConfigured()) return null;
const result = await runAdminQuery((client) =>
client
.from(SUPABASE_GUIDES_TABLE)
.update({
...updates,
updatedAt: new Date().toISOString(),
})
.eq("id", id)
.eq("userId", userId)
.select("*")
.single()
);
if (!result) return null;
if (result.error) {
throw new Error(
result.error.message || "Failed to update guide via Supabase"
);
}
return normalizeGuideRow(result.data);
}
// Ensure user exists in Supabase Users table before saving guides
async function ensureSupabaseUser(user) {
if (!isSupabaseAdminConfigured()) return false;
const SUPABASE_USERS_TABLE = supabaseTables.users;
// Check if user exists
const checkResult = await runAdminQuery((client) =>
client
.from(SUPABASE_USERS_TABLE)
.select("id")
.eq("id", user.id)
.maybeSingle()
);
if (checkResult?.data) {
console.log(`✅ User ${user.id} exists in Supabase Users table`);
return true;
}
// User doesn't exist, create them
console.log(`📝 Creating user ${user.id} in Supabase Users table...`);
const insertResult = await runAdminQuery((client) =>
client
.from(SUPABASE_USERS_TABLE)
.insert({
id: user.id,
email: user.email,
name: user.name || user.email.split("@")[0],
password: "supabase_auth", // Placeholder - actual auth is via Supabase Auth
subscription: user.subscription || "free",
guidesUsed: 0,
guidesLimit: 25, // Default for beta users
})
.select("id")
.single()
);
if (insertResult?.error) {
console.error("❌ Failed to create user in Supabase:", insertResult.error);
return false;
}
console.log(`✅ User ${user.id} created in Supabase Users table`);
return true;
}
// Load methodology files into memory for RAG
let methodologyDatabase = {};
function wrapGuideHtml(rawContent, meta = {}) {
if (!rawContent) return "";
const hasFullDocument =
rawContent.includes("");
if (hasFullDocument) {
return rawContent;
}
const {
characterName = "PREP101 Actor",
productionTitle = "",
productionType = "",
} = meta;
return `
${characterName} • ${productionTitle} Guide
Prep101 • ${productionType || "Audition Prep"}
${characterName}
${productionTitle || ""}
${rawContent}
`;
}
async function initializeDatabase() {
if (!sequelize || !testConnection) {
console.log("⚠️ Database not available - skipping initialization");
return;
}
try {
await testConnection();
await sequelize.sync({ alter: true });
console.log("✅ Database models synchronized");
} catch (error) {
console.error("❌ Database initialization failed:", error);
if (process.env.VERCEL) {
console.log(
"⚠️ Database connection failed in Vercel - continuing without database"
);
} else {
process.exit(1);
}
}
}
function loadMethodologyFiles() {
const methodologyPath = path.join(__dirname, "methodology");
if (!fs.existsSync(methodologyPath)) {
console.error(
"❌ Methodology folder not found! Please create ./methodology/ with your files"
);
return;
}
console.log("📚 Loading methodology files for RAG...");
try {
const files = fs.readdirSync(methodologyPath);
console.log(`📁 Found ${files.length} methodology files:`, files);
files.forEach((filename) => {
const filePath = path.join(methodologyPath, filename);
const content = fs.readFileSync(filePath, "utf8");
// Store with metadata for intelligent searching
methodologyDatabase[filename] = {
content: content,
filename: filename,
size: content.length,
type: determineFileType(filename),
keywords: extractKeywords(filename, content),
};
console.log(`✅ Loaded: ${filename} (${content.length} characters)`);
});
console.log(
`🧠 RAG Database Ready: ${
Object.keys(methodologyDatabase).length
} methodology files loaded`
);
} catch (error) {
console.error("❌ Failed to load methodology files:", error);
}
}
function determineFileType(filename) {
const name = filename.toLowerCase();
if (name.includes("character")) return "character-development";
if (name.includes("scene")) return "scene-work";
if (name.includes("comedy")) return "comedy";
if (name.includes("uta")) return "uta-hagen";
if (name.includes("cece") || name.includes("eloise")) return "example-guide";
if (name.includes("guide") || name.includes("example"))
return "example-guide";
return "general-methodology";
}
function extractKeywords(filename, content) {
const keywords = [];
const name = filename.toLowerCase();
// Add filename-based keywords
if (name.includes("character"))
keywords.push("character", "development", "psychology");
if (name.includes("archetype") || name.includes("comparable"))
keywords.push("archetype", "comparable", "reference", "similar characters");
if (name.includes("scene")) keywords.push("scene", "breakdown", "analysis");
if (name.includes("comedy")) keywords.push("comedy", "timing", "humor");
if (name.includes("uta"))
keywords.push("uta hagen", "9 questions", "methodology");
// Extract content-based keywords (simple approach)
const contentLower = content.toLowerCase();
if (contentLower.includes("subtext")) keywords.push("subtext");
if (contentLower.includes("objective")) keywords.push("objectives");
if (contentLower.includes("physicality")) keywords.push("physicality");
if (contentLower.includes("voice")) keywords.push("voice");
if (contentLower.includes("audition")) keywords.push("audition");
if (contentLower.includes("self-tape")) keywords.push("self-tape");
return keywords;
}
// Intelligent RAG search through methodology files
function searchMethodology(characterName, productionType, sceneContext) {
console.log(
`🔍 RAG Search: ${characterName} | ${productionType} | Context: ${sceneContext.substring(
0,
100
)}...`
);
const searchTerms = [
characterName.toLowerCase(),
productionType.toLowerCase(),
"character development",
"scene analysis",
"uta hagen",
"acting guide",
];
// Add production-type specific terms
if (productionType.toLowerCase().includes("comedy")) {
searchTerms.push("comedy", "timing", "humor");
}
if (productionType.toLowerCase().includes("drama")) {
searchTerms.push("drama", "emotion", "truth");
}
const relevantFiles = [];
// Score each methodology file based on relevance
Object.values(methodologyDatabase).forEach((file) => {
let relevanceScore = 0;
const fileContent = file.content.toLowerCase();
const fileKeywords = file.keywords;
// Score based on keywords
searchTerms.forEach((term) => {
if (fileKeywords.includes(term)) relevanceScore += 3;
if (fileContent.includes(term)) relevanceScore += 1;
});
// Boost example guides
if (file.type === "example-guide") relevanceScore += 5;
// Boost Uta Hagen methodology
if (file.type === "uta-hagen") relevanceScore += 4;
// Boost character development for all requests
if (file.type === "character-development") relevanceScore += 3;
if (relevanceScore > 0) {
relevantFiles.push({
...file,
relevanceScore: relevanceScore,
});
}
});
// Sort by relevance and return top results
const topResults = relevantFiles
.sort((a, b) => b.relevanceScore - a.relevanceScore)
.slice(0, 6); // Top 6 most relevant files
console.log(
`🎯 RAG Results: Found ${topResults.length} relevant methodology files`
);
topResults.forEach((file) => {
console.log(
` 📄 ${file.filename} (score: ${file.relevanceScore}, type: ${file.type})`
);
});
return topResults;
}
// PDF extraction using Adobe PDF Services
async function extractTextWithAdobe(pdfBuffer) {
const {
ServicePrincipalCredentials,
PDFServices,
MimeType,
ExtractPDFParams,
ExtractElementType,
ExtractPDFJob,
ExtractPDFResult,
SDKError,
ServiceUsageError,
ServiceApiError,
} = require("@adobe/pdfservices-node-sdk");
try {
// Create credentials from the credentials file
const credentials = ServicePrincipalCredentials.fromFile(
"pdfservices-api-credentials.json"
);
// Create PDF Services instance
const pdfServices = new PDFServices({ credentials });
// Create a readable stream from the buffer
const { Readable } = require("stream");
const stream = new Readable();
stream.push(pdfBuffer);
stream.push(null);
// Upload the PDF
const inputAsset = await pdfServices.upload({
readStream: stream,
mimeType: MimeType.PDF,
});
// Create parameters for text extraction
const params = new ExtractPDFParams({
elementsToExtract: [ExtractElementType.TEXT],
});
// Create and submit the job
const job = new ExtractPDFJob({ inputAsset, params });
const pollingURL = await pdfServices.submit({ job });
// Wait for completion and get result
const pdfServicesResponse = await pdfServices.getJobResult({
pollingURL,
resultType: ExtractPDFResult,
});
// Get the extracted text content
const resultAsset = pdfServicesResponse.result.resource;
const streamAsset = await pdfServices.getContent({ asset: resultAsset });
// Convert stream to text
const chunks = [];
for await (const chunk of streamAsset.readStream) {
chunks.push(chunk);
}
const extractedText = Buffer.concat(chunks).toString("utf8");
console.log(
"🔍 Adobe raw response (first 200 chars):",
extractedText.substring(0, 200)
);
let fullText = "";
// Try to parse as JSON first (structured format)
try {
const textData = JSON.parse(extractedText);
if (textData.elements) {
textData.elements.forEach((element) => {
if (element.Text) {
fullText += element.Text + "\n";
}
});
}
} catch (jsonError) {
// If JSON parsing fails, treat as plain text
console.log("🔍 JSON parsing failed, treating as plain text");
fullText = extractedText;
}
// Clean up the text while preserving structure
let cleanText = fullText
.replace(/\r\n/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
// Remove only known watermarks/footers
cleanText = cleanText
.replace(/Sides by Breakdown Services - Actors Access/gi, "")
.replace(/Page \d+\s+of\s+\d+/gi, "")
.replace(/B568CR-|74222 - .*? -/g, "")
.trim();
// Character tags: keep multiline, after we preserved \n
const characterPattern = /^(?:[A-Z][A-Z][A-Z\s]{1,40}):/gm; // e.g., "BRAD:" or "MRS. CARRUTHERS:"
const characterNames = [
...new Set(
(cleanText.match(characterPattern) || []).map((n) =>
n.replace(":", "").trim()
)
),
];
// Basic quality signal
const wordCount = (cleanText.match(/\b\w+\b/g) || []).length;
console.log("🔍 Adobe PDF Services Extraction:");
console.log("🔍 Text length:", cleanText.length);
console.log("🔍 Word count:", wordCount);
console.log("🔍 Character names found:", characterNames);
console.log("🔍 First 300 chars:", cleanText.substring(0, 300));
return {
text: cleanText,
method: "adobe-pdf-services",
confidence: wordCount > 120 ? "high" : wordCount > 40 ? "medium" : "low",
characterNames,
wordCount,
};
} catch (error) {
console.error("❌ Adobe PDF Services extraction failed:", error);
// Fallback to basic extraction if Adobe fails
console.log("🔄 Falling back to basic pdf-parse extraction...");
return await extractTextBasic(pdfBuffer);
}
}
// Fallback PDF extraction (keep the old function as backup)
async function extractTextBasic(pdfBuffer) {
const pdfParse = require("pdf-parse");
const data = await pdfParse(pdfBuffer);
// Preserve line breaks. Normalize only CRLF->LF and trim trailing spaces.
let text = data.text
.replace(/\r\n/g, "\n")
.replace(/[ \t]+\n/g, "\n") // strip end-of-line spaces
.replace(/\n{3,}/g, "\n\n") // collapse >2 blank lines to 1 blank line
.trim();
// Remove only known watermarks/footers; DO NOT blanket-replace digits or spaces
text = text
.replace(/Sides by Breakdown Services - Actors Access/gi, "")
.replace(/Page \d+\s+of\s+\d+/gi, "")
.replace(/B568CR-|74222 - .*? -/g, "")
.trim();
// Character tags: keep multiline, after we preserved \n
const characterPattern = /^(?:[A-Z][A-Z][A-Z\s]{1,40}):/gm; // e.g., "BRAD:" or "MRS. CARRUTHERS:"
const characterNames = [
...new Set(
(text.match(characterPattern) || []).map((n) => n.replace(":", "").trim())
),
];
// Basic quality signal
const wordCount = (text.match(/\b\w+\b/g) || []).length;
return {
text,
method: "basic",
confidence: wordCount > 120 ? "high" : wordCount > 40 ? "medium" : "low",
characterNames,
wordCount,
};
}
// RAG-Enhanced Guide Generation using your methodology files
async function generateActingGuideWithRAG(data) {
const fetch = require("node-fetch");
try {
console.log("🧠 Step 1: RAG - Searching your methodology files...");
// Search your methodology files for relevant content
const relevantMethodology = searchMethodology(
data.characterName,
data.productionType,
data.sceneText
);
// Build context from your methodology files (limit to ~120k chars to allow archetype file + examples)
let methodologyContext = "";
const MAX_METHODOLOGY_CHARS = 120000;
let currentChars = 0;
if (relevantMethodology.length > 0) {
const contextParts = [];
for (const file of relevantMethodology) {
const fileContext = `=== COREY RALSTON METHODOLOGY: ${file.filename} (Relevance: ${file.relevanceScore}) ===\n${file.content}\n\n`;
if (currentChars + fileContext.length <= MAX_METHODOLOGY_CHARS) {
contextParts.push(fileContext);
currentChars += fileContext.length;
} else {
console.log(
`⚠️ Skipping ${file.filename} to keep context under ${MAX_METHODOLOGY_CHARS} chars`
);
}
}
methodologyContext = contextParts.join("");
}
console.log(
`🎭 Step 2: Generating guide using ${relevantMethodology.length} methodology files...`
);
console.log(
`📊 Total methodology context: ${methodologyContext.length} characters`
);
// Build file type context for the AI
let fileTypeContext = "";
if (data.hasFullScript) {
fileTypeContext = `
**FILE TYPE CONTEXT:**
You have access to BOTH audition sides AND the full script. Use this to your advantage:
- **Full Script Context**: Reference the full script ONLY for character relationships, story arc, tone, and broader context
- **Audition Sides Focus**: Analyze and provide specific guidance ONLY on the uploaded audition sides
- **Smart Integration**: Pull relevant background information from the full script to enrich your analysis of the sides
- **Stay Focused**: Never give line-by-line notes on sections outside the audition sides
**IMPORTANT**: The full script provides context, but your analysis should focus entirely on the audition sides. Use the broader context to make the sides analysis richer and more informed.`;
} else {
fileTypeContext = `
**FILE TYPE CONTEXT:**
You are working with audition sides only. Focus your analysis on what's provided in the uploaded scenes.`;
}
// Generate guide using your methodology as context with timeout and retry logic
// Allow 4 minutes for Claude to generate (Vercel has 5-minute max)
const maxRetries = 2; // Allow one retry on timeout
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`🔄 Attempt ${attempt}/${maxRetries} to generate guide...`);
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 240000); // 4 minutes max for parent guide
// Debug scene content
console.log(
"📄 Scene text preview (first 500 chars):",
data.sceneText.substring(0, 500)
);
console.log("📄 Scene text length:", data.sceneText.length);
// Log what the model actually sees
console.log(
"🧾 SCRIPT PREVIEW:",
(data.sceneText || "").slice(0, 800).replace(/\n/g, "⏎"),
"... (len:",
(data.sceneText || "").length,
")"
);
const POLICY = `
SCRIPT INTEGRITY:
- Use ONLY facts present in SCRIPT below. If key facts (title, studio, location, time period) are not in the script, write "Not stated in sides" rather than inventing.
- Do NOT hallucinate project names, franchises, or studio info not explicitly in the script.
- For sparse scripts: acknowledge limited information, focus on what IS present, and give MORE imaginative/empathetic coaching to compensate.
- NO EVIDENCE TAGS or inline citations—trust the reader knows you're referencing the script. Just COACH.
- Tone: warm, direct, industry-savvy; balance encouragement with honest craft notes. Avoid generic motivational fluff.
`;
const response = await fetch("https://api.anthropic.com/v1/messages", {
signal: controller.signal,
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: DEFAULT_CLAUDE_MODEL,
max_tokens: DEFAULT_CLAUDE_MAX_TOKENS,
messages: [
{
role: "user",
content: `${POLICY}
You are PREP101, Corey Ralston's elite acting coach persona. You have access to Corey's complete methodology and reference files (Gold Standard Examples, Character Archetype Comparables, Voice Examples). Use them to deliver a personalized coaching guide that feels like Corey wrote it.
**COREY RALSTON'S METHODOLOGY & EXAMPLES:**
${methodologyContext}
**CURRENT AUDITION:**
CHARACTER: ${data.characterName}
PRODUCTION: ${data.productionTitle} (${data.productionType})
SCRIPT:
${data.sceneText}${fileTypeContext}
**VOICE & PERSONALITY**
- Talk directly to the actor ("You're about to...", "Your job is...").
- Open with a vivid hook that reframes the character's essence.
- Use emphatic caps sparingly and bold callouts (e.g., **Bold Choice:**, **Gold Acting Moment:**).
- Mix warmth, humor, and industry-truth honesty; always end with a FINAL PEP TALK.
**REQUIRED SECTIONS (IN THIS ORDER):**
1. **PROJECT OVERVIEW** - Project type, genre, tone/style. Name 3-5 comparable projects with 1-sentence explanations of WHY they're relevant (e.g., "Parks & Rec for the ensemble comedy rhythm"). Scene context + "Casting Director Mindset" (what they're REALLY looking for).
2. **CHARACTER BREAKDOWN** - Lead with a vivid character essence hook (see Voice Examples). Include:
- **Who They REALLY Are** (not the logline version—the lived-in truth)
- **How They See Themselves vs How Others See Them** (internal/external gap)
- **Your Bridge to [Character]** — 5+ reflective prompts to help the actor find personal connection ("Have you ever...?")
- **The Empathy Stretch** — What's DIFFERENT about this character's life from yours? How do you imaginatively access that?
- **Character Shortcut** — A vivid metaphor (e.g., "She's a golden retriever puppy in human form")
- **The Type (And How to Transcend It)** — Name the stereotype, then show how to make it three-dimensional
3. **UTA HAGEN'S 9 QUESTIONS** - Answer ALL NINE in first-person character voice. Be specific, grounded, imaginative. NO citations needed—just inhabit the character fully.
4. **SCENE-BY-SCENE BREAKDOWN** - For each scene:
- One-sentence emotional arc summary
- Beat-by-beat breakdown: What I'm DOING / What I'm REALLY thinking (subtext) / Physical life
- Identify the scene's emotional climax and how to earn it
5. **PHYSICALITY & MOVEMENT** - Translate psychology into body: posture, gestures, eye patterns, nervous habits, stillness vs movement. Include vocal life (pace, pitch, where they swallow emotion). Name 2-3 "signature moves" specific to THIS character. Self-tape framing notes.
6. **SUBTEXT & EMOTIONAL LAYERS** - For EVERY key line: "Line text" = Surface meaning → Subtext (the real need underneath). Map the emotional journey through the scenes. Name one "Trap to Avoid" and one "Secret Weapon" for this character.
7. **BOLD ACTING CHOICES** - The gold that books roles:
- **Trap vs Truth** table (Line | The Cliché Delivery | The Bold Choice)
- 3-4 "Surprising Shifts to Try" (e.g., "What if they LAUGH here instead of cry?")
- Genre-specific strategy
- "The Audition Trap" (what most actors will do wrong)
8. **MOMENT BEFORE & BUTTON** - Specific prep beats (60s/30s/10s/1s before) and multiple "button" options to end scenes with impact. Include physical punctuation ideas.
9. **REHEARSAL STRATEGY** - "Your 10+ Takes" approach (Natural/Bold/Vulnerable/Comedic/Smaller/Bigger etc.). One "Alternative Callback Take" ready to go. Memorization tips. Working-with-reader advice.
10. **ACTION PLAN** - Quick checklist: [ ] Week Before / [ ] Day Before / [ ] Day Of / [ ] After. Include emotional safety/decompression notes if material is heavy.
**END WITH:** A **FINAL PEP TALK** in Corey's voice—direct, warm, belief-filled. Make them feel ready to walk into that room and OWN it.
**PRODUCTION TYPE ADJUSTMENTS (APPLY WHEN RELEVANT):**
- **Multi-Cam Sitcom:** Mention live audience timing, "hold for laugh" guidance, bigger/cleaner physical choices, readable jokes.
- **Sketch Comedy:** Emphasize recurring character logic, signature behaviors, "Recurring Character Mindset", physical signature move.
- **Single-Cam Comedy:** Balance grounded truth with comedy, camera intimacy, reference shows like "The Office"/"Parks & Rec".
- **Streaming Drama/Prestige:** Highlight range, series arc, voice-over considerations, prestige comparables (Succession, Euphoria, etc.).
- **Feature Film:** Stress cinematic stillness, lens awareness, "The Camera Will Find You", film performance references.
- **Child/Family Project:** Keep language age-appropriate, add parent-friendly guidance, fun/emojis allowed, shorter digestible paragraphs.
**QUALITY IMPERATIVES**
- NEVER invent production facts; write "Not stated in sides" when missing. NO inline citations or [evidence] tags—just write naturally.
- Include at least 3 comparable projects with clear "why" explanations.
- AVOID REPETITION: Each section should add NEW insights, not repeat what was said earlier. If you've covered a point, move on.
- Make EVERY line of subtext analysis SPECIFIC to the actual dialogue—don't generalize.
- Bridge to Character prompts should feel deeply personal and imaginative, not generic.
- Pull archetype comparisons from character_archetype_comparables.md when they illuminate the role.
- Highlight "Bold Choice", "Gold Acting Moment", "Pitfall to Avoid" ONLY where they add genuine value—not as filler.
- Write to INSPIRE and STRATEGIZE, not just inform. This is coaching, not a book report.
- ${
data.hasFullScript
? "Use full-script knowledge only to enrich side-specific analysis (avoid spoilers)."
: "Focus analysis strictly on the provided audition sides."
}
**DELIVERABLE REMINDERS**
- Use HTML-friendly headings, paragraphs, and lists; keep the required order.
- Provide actionable coaching, not summaries; every section should end with playable insights.
- Always conclude with a FINAL PEP TALK in Corey's voice.
**CRITICAL HTML STYLING RULES:**
- NEVER use light text colors (white, #fff, #ffffff, light gray, etc.) on light backgrounds
- NEVER use inline styles with light background colors (#fff, #ffffff, #fdf, #eff, #f5f, #f0f, #fef, #fffb, #f8fa, rgba(255, rgba(253, rgba(239, rgba(245, rgba(240, rgba(254, rgba(248, etc.)
- If you must use background colors, use DARK backgrounds with LIGHT text, or NO background colors at all
- The guide will be displayed on a dark background (#1f2937), so all text should be light/readable colors
- DO NOT add inline style attributes with color or background-color properties
- Let the CSS handle all styling - just use semantic HTML
**OUTPUT FORMAT:** Output ONLY the raw HTML content without any markdown formatting, code blocks, or \`\`\`html wrappers. The response should be pure HTML that can be directly inserted into a web page. Make it worthy of the PREP101 brand and indistinguishable from Corey's personal coaching.`,
},
],
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(
`❌ RAG Guide Generation Error (Attempt ${attempt}) [model=${DEFAULT_CLAUDE_MODEL}]:`,
response.status,
response.statusText,
errorText
);
if (response.status === 504 && attempt < maxRetries) {
console.log(
`⏰ Gateway timeout, retrying in ${attempt * 2} seconds...`
);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
lastError = new Error(`Gateway timeout (Attempt ${attempt})`);
continue;
}
throw new Error(`Anthropic ${response.status}: ${errorText}`);
}
const result = await response.json();
if (result.content && result.content[0] && result.content[0].text) {
console.log(`✅ RAG Guide generated using Corey's methodology!`);
console.log(
`📊 Guide length: ${result.content[0].text.length} characters`
);
console.log(
`🎯 Methodology files used: ${relevantMethodology.length}`
);
return result.content[0].text;
} else {
throw new Error("Invalid response format from API");
}
} catch (error) {
lastError = error;
if (error.name === "AbortError") {
console.error(`⏰ Request timeout on attempt ${attempt}`);
if (attempt < maxRetries) {
console.log(`🔄 Retrying after timeout...`);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
continue;
}
}
if (attempt < maxRetries) {
console.log(
`🔄 Attempt ${attempt} failed, retrying in ${
attempt * 2
} seconds...`
);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
continue;
}
console.error(`❌ All ${maxRetries} attempts failed`);
throw error;
}
}
throw (
lastError ||
new Error("Failed to generate guide after all retry attempts")
);
} catch (error) {
console.error("❌ RAG guide generation failed:", error.message);
throw error;
}
}
// Analyze script content to determine color theme
function determineColorTheme(
characterName,
productionTitle,
productionType,
sceneText
) {
const text =
`${characterName} ${productionTitle} ${productionType} ${sceneText}`.toLowerCase();
// Gender-specific themes (check character names first)
const characterNameLower = characterName.toLowerCase();
// Princess/Female character themes
if (
characterNameLower.includes("princess") ||
characterNameLower.includes("queen") ||
characterNameLower.includes("fairy") ||
characterNameLower.includes("rose") ||
characterNameLower.includes("lily") ||
characterNameLower.includes("belle") ||
characterNameLower.includes("ariel") ||
characterNameLower.includes("snow")
) {
return {
primary: "#EC4899", // Pink
secondary: "#F59E0B", // Gold
accent: "#8B5CF6", // Purple
background: "#FDF2F8", // Light pink
name: "Princess",
};
}
// Prince/Male character themes
if (
characterNameLower.includes("prince") ||
characterNameLower.includes("king") ||
characterNameLower.includes("knight") ||
characterNameLower.includes("hero") ||
characterNameLower.includes("warrior") ||
characterNameLower.includes("dragon") ||
characterNameLower.includes("max") ||
characterNameLower.includes("leo")
) {
return {
primary: "#3B82F6", // Blue
secondary: "#F59E0B", // Gold
accent: "#10B981", // Green
background: "#EFF6FF", // Light blue
name: "Prince",
};
}
// Adventure/Action themes
if (
text.includes("adventure") ||
text.includes("action") ||
text.includes("quest") ||
text.includes("hero") ||
text.includes("battle") ||
text.includes("journey") ||
text.includes("explorer") ||
text.includes("warrior") ||
text.includes("knight")
) {
return {
primary: "#4F46E5", // Vibrant blue
secondary: "#F59E0B", // Orange
accent: "#10B981", // Green
background: "#EFF6FF", // Light blue
name: "Adventure",
};
}
// Comedy/Fun themes
if (
text.includes("comedy") ||
text.includes("funny") ||
text.includes("humor") ||
text.includes("silly") ||
text.includes("joke") ||
text.includes("laugh") ||
text.includes("playful") ||
text.includes("wacky") ||
text.includes("goofy")
) {
return {
primary: "#EC4899", // Pink
secondary: "#F59E0B", // Yellow
accent: "#8B5CF6", // Purple
background: "#FDF2F8", // Light pink
name: "Comedy",
};
}
// Fantasy/Magical themes
if (
text.includes("fantasy") ||
text.includes("magic") ||
text.includes("wizard") ||
text.includes("fairy") ||
text.includes("dragon") ||
text.includes("spell") ||
text.includes("enchanted") ||
text.includes("mythical") ||
text.includes("wonder")
) {
return {
primary: "#8B5CF6", // Purple
secondary: "#EC4899", // Pink
accent: "#F59E0B", // Gold
background: "#F5F3FF", // Light purple
name: "Fantasy",
};
}
// Drama/Serious themes
if (
text.includes("drama") ||
text.includes("serious") ||
text.includes("emotional") ||
text.includes("intense") ||
text.includes("deep") ||
text.includes("powerful") ||
text.includes("meaningful") ||
text.includes("touching") ||
text.includes("heartfelt")
) {
return {
primary: "#7C3AED", // Purple
secondary: "#14B8A6", // Teal
accent: "#6B7280", // Gray
background: "#F0FDFA", // Light teal
name: "Drama",
};
}
// Modern/Urban themes
if (
text.includes("modern") ||
text.includes("urban") ||
text.includes("city") ||
text.includes("contemporary") ||
text.includes("trendy") ||
text.includes("cool") ||
text.includes("street") ||
text.includes("hip") ||
text.includes("current")
) {
return {
primary: "#3B82F6", // Blue
secondary: "#6B7280", // Gray
accent: "#EF4444", // Red
background: "#F8FAFC", // Light gray
name: "Modern",
};
}
// Princess/Royal themes
if (
text.includes("princess") ||
text.includes("royal") ||
text.includes("queen") ||
text.includes("king") ||
text.includes("crown") ||
text.includes("castle") ||
text.includes("noble") ||
text.includes("elegant") ||
text.includes("regal")
) {
return {
primary: "#EC4899", // Pink
secondary: "#F59E0B", // Gold
accent: "#8B5CF6", // Purple
background: "#FDF2F8", // Light pink
name: "Royal",
};
}
// Superhero themes
if (
text.includes("superhero") ||
text.includes("hero") ||
text.includes("power") ||
text.includes("save") ||
text.includes("rescue") ||
text.includes("strong") ||
text.includes("mighty") ||
text.includes("brave") ||
text.includes("courage")
) {
return {
primary: "#EF4444", // Red
secondary: "#F59E0B", // Gold
accent: "#3B82F6", // Blue
background: "#FEF2F2", // Light red
name: "Superhero",
};
}
// Nature/Outdoor themes
if (
text.includes("nature") ||
text.includes("outdoor") ||
text.includes("forest") ||
text.includes("garden") ||
text.includes("animal") ||
text.includes("tree") ||
text.includes("flower") ||
text.includes("mountain") ||
text.includes("river")
) {
return {
primary: "#10B981", // Green
secondary: "#F59E0B", // Orange
accent: "#8B5CF6", // Purple
background: "#F0FDF4", // Light green
name: "Nature",
};
}
// Production type specific themes
if (productionType.toLowerCase().includes("musical")) {
return {
primary: "#EC4899", // Pink
secondary: "#F59E0B", // Gold
accent: "#8B5CF6", // Purple
background: "#FDF2F8", // Light pink
name: "Musical",
};
}
if (productionType.toLowerCase().includes("comedy")) {
return {
primary: "#F59E0B", // Yellow
secondary: "#EC4899", // Pink
accent: "#10B981", // Green
background: "#FFFBEB", // Light yellow
name: "Comedy",
};
}
if (productionType.toLowerCase().includes("drama")) {
return {
primary: "#7C3AED", // Purple
secondary: "#14B8A6", // Teal
accent: "#6B7280", // Gray
background: "#F0FDFA", // Light teal
name: "Drama",
};
}
if (
productionType.toLowerCase().includes("action") ||
productionType.toLowerCase().includes("adventure")
) {
return {
primary: "#EF4444", // Red
secondary: "#F59E0B", // Gold
accent: "#3B82F6", // Blue
background: "#FEF2F2", // Light red
name: "Action",
};
}
// Seasonal and holiday themes
if (
text.includes("christmas") ||
text.includes("holiday") ||
text.includes("winter")
) {
return {
primary: "#EF4444", // Red
secondary: "#10B981", // Green
accent: "#F59E0B", // Gold
background: "#FEF2F2", // Light red
name: "Christmas",
};
}
if (
text.includes("halloween") ||
text.includes("spooky") ||
text.includes("ghost")
) {
return {
primary: "#8B5CF6", // Purple
secondary: "#F59E0B", // Orange
accent: "#EF4444", // Red
background: "#F5F3FF", // Light purple
name: "Halloween",
};
}
if (
text.includes("easter") ||
text.includes("spring") ||
text.includes("bunny")
) {
return {
primary: "#EC4899", // Pink
secondary: "#10B981", // Green
accent: "#FCD34D", // Yellow
background: "#FDF2F8", // Light pink
name: "Easter",
};
}
if (
text.includes("summer") ||
text.includes("beach") ||
text.includes("ocean")
) {
return {
primary: "#3B82F6", // Blue
secondary: "#FCD34D", // Yellow
accent: "#10B981", // Green
background: "#EFF6FF", // Light blue
name: "Summer",
};
}
// Default: Friendly and approachable
let theme = {
primary: "#10B981", // Green
secondary: "#F59E0B", // Orange
accent: "#3B82F6", // Blue
background: "#F0FDF4", // Light green
name: "Friendly",
};
// Age-specific color adjustments
if (
text.includes("baby") ||
text.includes("toddler") ||
text.includes("little")
) {
// Softer, pastel colors for very young characters
theme.primary = "#F472B6"; // Soft pink
theme.secondary = "#FCD34D"; // Soft yellow
theme.accent = "#A78BFA"; // Soft purple
theme.background = "#FDF2F8"; // Very light pink
theme.name = "Baby-Friendly";
} else if (
text.includes("teen") ||
text.includes("older") ||
text.includes("mature")
) {
// More sophisticated colors for older characters
theme.primary = "#7C3AED"; // Deeper purple
theme.secondary = "#14B8A6"; // Teal
theme.accent = "#6B7280"; // Gray
theme.background = "#F8FAFC"; // Light gray
theme.name = "Teen-Friendly";
}
return theme;
}
// Generate sample HTML template with the determined color theme
function generateHTMLTemplate(colorTheme, characterName, productionTitle) {
return `
${characterName} - ${productionTitle} - Child's Guide
`;
}
// Child's Guide Generation Function
async function generateChildGuide(data) {
const fetch = require("node-fetch");
try {
console.log("🌟 Generating simplified Child's Guide...");
// Search methodology for child-friendly examples
const childMethodology = searchMethodology(
data.characterName,
data.productionType,
data.sceneText
);
// Build context from child-friendly methodology
let childMethodologyContext = "";
if (childMethodology.length > 0) {
childMethodologyContext = childMethodology
.map(
(file) =>
`=== CHILD-FRIENDLY METHODOLOGY: ${file.filename} ===\n${file.content}\n\n`
)
.join("");
}
console.log(
`🎭 Generating child guide using ${childMethodology.length} methodology files...`
);
// Determine color theme based on content
const colorTheme = determineColorTheme(
data.characterName,
data.productionTitle,
data.productionType,
data.sceneText
);
console.log(`🎨 Using ${colorTheme.name} color theme for child guide`);
// Add timeout to child guide generation to prevent Vercel timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 100000); // 100 second timeout for child guide
try {
// Generate child guide using the parent guide as reference
const response = await fetch("https://api.anthropic.com/v1/messages", {
signal: controller.signal,
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: DEFAULT_CLAUDE_MODEL,
max_tokens: DEFAULT_CLAUDE_MAX_TOKENS,
messages: [
{
role: "user",
content: `You are Corey Ralston, a witty, experienced youth acting coach.
Your task is to create a simplified, fun, and empowering "Child's Guide" for young actors (ages 8-12), based on the parent-facing audition prep guide.
## Voice & Style
- Friendly, encouraging, and conversational — talk **to the child directly**.
- Keep language simple but not babyish.
- Fun tone with positive energy, like a coach who believes in them.
- Use emojis sparingly for emphasis (🌟, 🎭, 🎬) — no overuse.
- Add clear, bold section headers for easy reading.
- Keep paragraphs short and scannable.
## Structure
Your guide must follow this flow:
1. **Big Welcome**
- Greet the actor, mention the role name and project title, and remind them they've got this.
2. **About Your Character**
- Describe who they are, what makes them unique, and their personality.
- Keep it relatable and fun, like explaining to a friend.
3. **What's Happening in the Scene**
- Explain the scene setup in simple language.
4. **Acting Jobs (Action Plan)**
- Numbered list of 3-5 specific things they need to focus on in the scene.
- Use bold keywords for clarity.
5. **Fun Acting Tips**
- Ideas for how they can explore choices (different voices, physicality, emotions).
6. **Moment Before & Button**
- A simple explanation of what happens right before the scene and how to finish strong.
7. **Practice Ideas**
- Easy practice tasks or "games" to rehearse their choices.
8. **Final Encouragement**
- Short, upbeat closing that reminds them they are ready and capable.
## HTML Styling & Colors
Create a complete HTML document with embedded CSS using this EXACT color theme:
**${colorTheme.name.toUpperCase()} THEME COLORS:**
- Primary: ${colorTheme.primary}
- Secondary: ${colorTheme.secondary}
- Accent: ${colorTheme.accent}
- Background: ${colorTheme.background}
**HTML TEMPLATE REFERENCE:**
Use this structure and styling approach (replace the placeholder content with your guide):
${generateHTMLTemplate(colorTheme, data.characterName, data.productionTitle)}
**CRITICAL COLOR & STYLING RULES:**
- NEVER use light text colors (white, #fff, light gray, etc.) on light backgrounds
- NEVER use inline style attributes with light background colors - the CSS will handle all styling
- If you use the provided color theme, ensure DARK text (#1f2937, #333, etc.) on light backgrounds
- DO NOT add inline color or background-color styles - let the CSS classes handle styling
- Use the exact colors provided above
- Follow the CSS class names from the template (.section, .highlight-box, .tip-box, .number-list)
- Keep the fun, youthful design with rounded corners, shadows, and gradients
- Make sure all content is properly wrapped in the HTML structure
2. **Youthful Design Elements**:
- Rounded corners (border-radius: 12px)
- Soft shadows (box-shadow: 0 4px 20px rgba(0,0,0,0.1))
- Fun fonts (Google Fonts: 'Comic Neue', 'Fredoka One', 'Bubblegum Sans')
- Gradient backgrounds
- Emoji icons for section headers
- Colorful accent borders
3. **Responsive Layout**:
- Mobile-friendly design
- Easy-to-read typography
- Clear visual hierarchy
- Comfortable spacing
## Rules
- NO overly adult jargon — explain complex ideas in kid-friendly terms.
- NO summarizing or shortening beyond what's needed for age clarity — keep the guide complete and helpful.
- Reference the parent guide's insights for accuracy but rewrite it in a playful, empowering style.
- When the role skews younger (under 8), simplify even further and lean into fun phrasing and examples.
- ALWAYS include complete HTML with embedded CSS styling and appropriate colors.
## References
Match the tone, depth, and structure of these examples:
- Tucker's Guide (age 9)
- Eloise's Guide (age 10)
- Alanna's Guide (age 4-6)
- Alma's Guide (age 8)
## Current Project
CHARACTER: ${data.characterName}
PRODUCTION: ${data.productionTitle} (${data.productionType})
SCRIPT:
${data.sceneText}
## Parent Guide Reference
${data.parentGuideContent.substring(0, 2000)}...
## Child-Friendly Methodology
${childMethodologyContext}
**OUTPUT FORMAT:** Output ONLY the raw HTML content without any markdown formatting, code blocks, or \`\`\`html wrappers. The response should be a complete HTML document with embedded CSS styling, fun colors, and perfect for young actors!`,
},
],
}),
});
clearTimeout(timeoutId); // Clear timeout if request completes
if (!response.ok) {
const errorText = await response.text();
console.error(
"❌ Child Guide Generation Error:",
response.status,
errorText
);
throw new Error(`API Error: ${response.status} - ${errorText}`);
}
const result = await response.json();
if (result.content && result.content[0] && result.content[0].text) {
console.log(`✅ Child's Guide generated successfully!`);
console.log(
`📊 Child guide length: ${result.content[0].text.length} characters`
);
return result.content[0].text;
} else {
throw new Error("Invalid response format from API");
}
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
console.error("⏰ Child guide generation timeout after 90 seconds");
throw new Error("Child guide generation timed out");
}
console.error("❌ Child guide generation failed:", error.message);
throw error;
}
} catch (error) {
console.error("❌ Child guide generation outer error:", error.message);
throw error;
}
}
// Async child guide generation - runs in background on serverful, or via separate endpoint on serverless
async function generateChildGuideAsync({ guideId, childData, userId }) {
let persistence = "sequelize";
let guideRecord = null;
let GuideModel = null;
try {
GuideModel = Guide || require("./models/Guide");
} catch (_) {
GuideModel = null;
}
try {
if (GuideModel) {
guideRecord = await GuideModel.findByPk(guideId);
} else if (isSupabaseAdminConfigured()) {
persistence = "supabase";
guideRecord = await supabaseFetchGuide({ id: guideId });
} else {
console.warn(
"⚠️ No database models or Supabase admin client available - child guide generation skipped."
);
return { success: false, error: "No database available" };
}
if (!guideRecord) {
console.warn(
`⚠️ Child guide generation skipped - guide ${guideId} not found`
);
return { success: false, error: "Guide not found" };
}
console.log(
`🌟 Child guide generation started for guide ${
guideRecord.guideId || guideId
}`
);
const childHtml = await generateChildGuide(childData);
const supabaseUserId =
userId || guideRecord?.userId || guideRecord?.user_id;
if (
persistence === "sequelize" &&
GuideModel &&
typeof guideRecord.update === "function"
) {
await guideRecord.update({
childGuideHtml: childHtml,
childGuideCompleted: true,
});
} else if (supabaseUserId) {
await supabaseUpdateGuide(guideId, supabaseUserId, {
childGuideHtml: childHtml,
childGuideCompleted: true,
});
} else {
console.warn(
`⚠️ Unable to persist child guide for ${guideId}: missing userId`
);
return { success: false, error: "Missing userId" };
}
console.log(
`✅ Child guide stored for ${guideRecord.characterName} (${
guideRecord.guideId || guideId
})`
);
return { success: true, childHtml };
} catch (error) {
console.error("❌ Child guide generation failed:", error);
try {
if (persistence === "sequelize" && GuideModel) {
await GuideModel.update(
{ childGuideCompleted: false },
{ where: { id: guideId } }
);
} else if (guideRecord) {
const supabaseUserId =
userId || guideRecord?.userId || guideRecord?.user_id;
if (supabaseUserId) {
await supabaseUpdateGuide(guideId, supabaseUserId, {
childGuideCompleted: false,
});
}
}
} catch (updateError) {
console.error(
"❌ Failed to persist child guide failure status:",
updateError
);
}
return { success: false, error: error.message };
}
}
function queueChildGuideGeneration({ guideId, childData, userId }) {
// In Vercel serverless, run synchronously but don't await (fire and forget with internal error handling)
// This works because Vercel keeps the function alive for a bit after response is sent
if (process.env.VERCEL) {
console.log(
`🌟 Starting child guide generation for ${guideId} (Vercel serverless mode)`
);
// Fire and forget - the function has internal error handling
generateChildGuideAsync({ guideId, childData, userId })
.then((result) => {
if (result.success) {
console.log(`✅ Child guide generated for ${guideId} in Vercel mode`);
} else {
console.error(`❌ Child guide failed for ${guideId}:`, result.error);
}
})
.catch((err) => {
console.error(`❌ Child guide error for ${guideId}:`, err);
});
return;
}
// In serverful mode (local dev, Railway), use setImmediate for background processing
setImmediate(async () => {
await generateChildGuideAsync({ guideId, childData, userId });
});
}
// Handle OPTIONS preflight for upload endpoint
app.options("/api/upload", (req, res) => {
res.header("Access-Control-Allow-Origin", req.headers.origin || "*");
res.header("Access-Control-Allow-Methods", "POST, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
res.header("Access-Control-Allow-Credentials", "true");
res.header("Access-Control-Max-Age", "86400"); // 24 hours
res.status(200).end();
});
// PDF Upload endpoint
app.post("/api/upload", upload.single("file"), async (req, res) => {
try {
if (!req.file || req.file.mimetype !== "application/pdf") {
return res.status(400).json({ error: "Please upload a PDF file" });
}
console.log(`📄 Processing: ${req.file.originalname}`);
const MIN_WC = parseInt(process.env.MIN_EXTRACT_WORDS || "200", 10);
// 1) Adobe first (only if available)
let result;
if (extractWithAdobe) {
result = await extractWithAdobe(req.file.buffer).catch((e) => ({
success: false,
method: "adobe",
reason: e?.message || "adobe-extract-error",
}));
} else {
console.log(
"[UPLOAD] Adobe extractor not available, using basic extraction"
);
result = {
success: false,
method: "adobe",
reason: "adobe-not-available",
};
}
if (!result?.success || !result.text) {
console.warn(
"[UPLOAD] Adobe failed or empty:",
result?.reason || "no-text"
);
result = await extractWithBasic(req.file.buffer);
}
// OCR Fallback: If basic extraction fails or produces poor quality content, try OCR
if (!result?.text || result.text.length < 100) {
console.log("[UPLOAD] Basic extraction failed, trying OCR fallback...");
try {
result = await extractWithOCR(req.file.buffer);
console.log("[UPLOAD] OCR extraction completed:", {
method: result.method,
wordCount: result.wordCount,
confidence: result.confidence,
});
} catch (ocrError) {
console.error("[UPLOAD] OCR fallback failed:", ocrError.message);
}
}
// Additional OCR fallback: If content quality is poor, try OCR
const initialContentQuality = assessContentQuality(
result.text,
result.wordCount || 0,
true
);
if (initialContentQuality.quality === "poor" && result.method === "basic") {
console.log(
"[UPLOAD] Basic extraction produced poor quality content, trying OCR fallback..."
);
try {
const ocrResult = await extractWithOCR(req.file.buffer);
const ocrContentQuality = assessContentQuality(
ocrResult.text,
ocrResult.wordCount || 0,
true
);
// Use OCR result if it's better quality
if (ocrContentQuality.quality !== "poor") {
console.log(
"[UPLOAD] OCR produced better quality content, using OCR result"
);
result = ocrResult;
} else {
console.log(
"[UPLOAD] OCR also produced poor quality content, keeping basic result"
);
}
} catch (ocrError) {
console.error("[UPLOAD] OCR fallback failed:", ocrError.message);
}
}
// 2) Assess content quality and handle low-quality content (lenient check on upload)
const contentQuality = assessContentQuality(
result.text,
result.wordCount || 0,
true
);
if (contentQuality.quality === "poor") {
console.warn(
`[UPLOAD] Poor content quality detected: ${contentQuality.reason}`,
{
filename: req.file.originalname,
wordCount: result.wordCount,
watermarkRatio: contentQuality.watermarkRatio,
}
);
return res.status(422).json({
success: false,
error:
contentQuality.reason === "watermark_heavy"
? "Limited content: please upload clean sides without watermarks or timestamps"
: "Limited content: please upload a script with actual dialogue and scene content",
contentQuality: contentQuality.reason,
extractionMethod: result.method,
extractionConfidence: result.confidence || "low",
wordCount: result.wordCount,
watermarkRatio: contentQuality.watermarkRatio,
});
}
if (contentQuality.quality === "low") {
console.log(
`[UPLOAD] Low content quality - allowing fallback generation`,
{
filename: req.file.originalname,
wordCount: result.wordCount,
reason: contentQuality.reason,
}
);
}
const uploadId = Date.now().toString();
const fileType = req.body.fileType || "sides"; // Default to sides if not specified
// 3) Character names (if Adobe didn't supply them)
const characterPattern = /^[A-Z][A-Z\s]+:/gm;
const characterNames = result.characterNames || [
...new Set(
(result.text.match(characterPattern) || []).map((n) =>
n.replace(":", "").trim()
)
),
];
uploads[uploadId] = {
filename: req.file.originalname,
sceneText: result.text.trim(),
characterNames,
extractionMethod: result.method,
extractionConfidence: result.confidence,
uploadTime: new Date(),
wordCount: result.wordCount,
fileType: fileType, // Store the file type
userId: req.userId || req.user?.id || null, // Store user ID for better tracking
};
// Log upload storage for debugging
console.log("[UPLOAD] Stored upload:", {
uploadId,
filename: req.file.originalname,
userId: uploads[uploadId].userId,
totalUploads: Object.keys(uploads).length,
timestamp: new Date().toISOString()
});
// 5) Log triage with enhanced preview
const preview = (result.text || "").slice(0, 300).replace(/\n/g, "⏎");
console.log("[UPLOAD]", {
file: req.file.originalname,
method: result.method,
confidence: result.confidence,
words: result.wordCount,
contentQuality: contentQuality.quality,
preview: `"${preview}..."`,
});
// Update extraction diagnostics for /api/health
try {
const m = result.method || "unknown";
if (extractionStats.totals[m] !== undefined)
extractionStats.totals[m] += 1;
extractionStats.last = {
method: result.method,
confidence: result.confidence,
wordCount: result.wordCount,
filename: req.file.originalname,
at: new Date().toISOString(),
};
} catch (_) {}
// 6) Respond
return res.json({
success: true,
uploadId,
filename: req.file.originalname,
textLength: result.text.length,
wordCount: result.wordCount,
characterNames,
extractionMethod: result.method,
extractionConfidence: result.confidence,
preview: (result.text || "").slice(0, 400) + "...",
sceneText: result.text,
});
} catch (error) {
console.error("❌ Upload error:", error);
res.status(500).json({ error: "Failed to process PDF: " + error.message });
}
});
// RAG-Enhanced Guide Generation Endpoint
app.post("/api/guides/generate", auth, async (req, res) => {
const requestStartTime = Date.now(); // Track start time for Vercel timeout management
try {
const {
uploadId,
uploadIds,
characterName,
productionTitle,
productionType,
roleSize,
genre,
storyline,
characterBreakdown,
callbackNotes,
focusArea,
childGuideRequested,
} = req.body;
// Handle both single and multiple upload IDs
const uploadIdList = uploadIds
? Array.isArray(uploadIds)
? uploadIds
: [uploadIds]
: uploadId
? [uploadId]
: [];
// Debug request basics for faster triage
console.log("📝 Generate request:", {
uploadIdsCount: uploadIdList.length,
uploadIds: uploadIdList,
hasAuthHeader: !!req.headers.authorization,
hasCharacterName: !!characterName,
hasProductionTitle: !!productionTitle,
hasProductionType: !!productionType,
availableUploads: Object.keys(uploads).length,
availableUploadIds: Object.keys(uploads).slice(0, 5), // First 5 for debugging
});
// Check if upload IDs exist and provide detailed error info
const missingIds = uploadIdList.filter((id) => !uploads[id]);
const availableIds = Object.keys(uploads);
if (!uploadIdList.length) {
console.error("[GENERATE] No upload IDs provided in request");
return res.status(400).json({
error: "No upload ID(s) provided. Please upload your PDF first.",
debug: { received: { uploadId, uploadIds } }
});
}
if (missingIds.length > 0) {
console.error("[GENERATE] Missing upload IDs:", {
requested: uploadIdList,
missing: missingIds,
available: availableIds.slice(0, 5), // Show first 5 for debugging
totalAvailable: availableIds.length,
timestamp: new Date().toISOString()
});
// In serverless, uploads can expire between requests
// Provide helpful error message
return res.status(400).json({
error: "Upload session expired. This can happen in serverless environments. Please re-upload your PDF and try generating immediately.",
debug: {
missingIds,
availableCount: availableIds.length,
tip: "Upload your PDF and generate the guide in the same session without delay"
}
});
}
if (!characterName || !productionTitle || !productionType) {
return res.status(400).json({
error: "Missing required fields",
});
}
const currentUser =
req.user || (User && req.userId ? await User.findByPk(req.userId) : null);
if (!currentUser) {
return res.status(401).json({
success: false,
error: "Authentication required to save guide",
});
}
const userGuidesUsed = currentUser.guidesUsed || 0;
const userGuideLimit =
typeof currentUser.guidesLimit === "number"
? currentUser.guidesLimit
: null;
const isAdminUser =
currentUser.betaAccessLevel === "admin" ||
currentUser.subscription === "admin";
const hasUnlimitedPlan =
isAdminUser ||
userGuideLimit === null ||
userGuideLimit <= 0 ||
userGuideLimit >= 999 ||
(currentUser.subscription &&
currentUser.subscription.toLowerCase() !== "free");
if (
!hasUnlimitedPlan &&
userGuideLimit !== null &&
userGuideLimit > 0 &&
userGuidesUsed >= userGuideLimit
) {
return res.status(403).json({
success: false,
error:
"Monthly guide limit reached. Upgrade your subscription for more guides.",
guidesUsed: userGuidesUsed,
guidesLimit: userGuideLimit,
});
}
// Combine all upload data
const scenePayloads = req.body.scenePayloads || {};
const allUploadData = uploadIdList.map((id) => {
if (uploads[id]) return uploads[id];
const fallback = scenePayloads[id];
if (fallback && fallback.sceneText) {
return {
filename: fallback.filename || `upload_${id}.txt`,
sceneText: fallback.sceneText,
characterNames: fallback.characterNames || [],
extractionMethod: fallback.extractionMethod || "client-cache",
extractionConfidence: fallback.extractionConfidence || "unknown",
uploadTime: new Date(),
wordCount:
fallback.wordCount ||
(fallback.sceneText.match(/\b\w+\b/g) || []).length,
fileType: fallback.fileType || "sides",
};
}
return null;
});
if (allUploadData.some((data) => !data)) {
return res
.status(400)
.json({ error: "Upload data not found or expired. Please re-upload." });
}
const combinedSceneText = allUploadData
.map((data) => data.sceneText)
.join("\n\n--- NEW SCENE ---\n\n");
const combinedWordCount = allUploadData.reduce(
(total, data) => total + (data.wordCount || 0),
0
);
console.log(`🎭 COREY RALSTON RAG Guide Generation...`);
console.log(`🎬 ${characterName} | ${productionTitle} (${productionType})`);
console.log(
`🧠 Using ${Object.keys(methodologyDatabase).length} methodology files`
);
// Check if we have full script context
const hasFullScript = allUploadData.some(
(data) => data.fileType === "full_script"
);
const hasSides = allUploadData.some((data) => data.fileType === "sides");
console.log(
`📚 File types detected: ${allUploadData
.map((d) => d.fileType)
.join(", ")}`
);
console.log(`🎭 Has sides: ${hasSides}, Has full script: ${hasFullScript}`);
// Quality assessment - very lenient since we already validated at upload
const contentQuality = assessContentQuality(
combinedSceneText,
combinedWordCount,
false
);
// Only reject if TRULY terrible (< 10 words or > 80% corrupted)
if (
contentQuality.quality === "poor" &&
(combinedWordCount < 10 ||
(contentQuality.repetitiveRatio &&
contentQuality.repetitiveRatio > 0.8) ||
(contentQuality.repetitionRatio &&
contentQuality.repetitionRatio > 0.8))
) {
let errorMessage =
"Unable to generate guide: content appears to be corrupted or empty";
if (contentQuality.repetitiveRatio > 0.8) {
errorMessage =
"Unable to generate guide: content is mostly watermarks/timestamps (>80%)";
} else if (contentQuality.repetitionRatio > 0.8) {
errorMessage =
"Unable to generate guide: content is mostly repetitive text (>80%)";
} else if (combinedWordCount < 10) {
errorMessage =
"Unable to generate guide: insufficient content (less than 10 words)";
}
console.warn("[GENERATION] Rejecting due to poor quality:", {
wordCount: combinedWordCount,
repetitiveRatio: contentQuality.repetitiveRatio,
repetitionRatio: contentQuality.repetitionRatio,
reason: contentQuality.reason,
});
return res.status(422).json({
success: false,
error: errorMessage,
contentQuality: contentQuality.reason,
details: {
combinedWordCount,
repetitiveRatio: contentQuality.repetitiveRatio,
repetitionRatio: contentQuality.repetitionRatio,
},
});
}
// If we made it here, content is acceptable - log for monitoring
if (contentQuality.quality === "low") {
console.log("[GENERATION] Low quality content but proceeding:", {
wordCount: combinedWordCount,
reason: contentQuality.reason,
});
}
const guideContentRaw = await generateActingGuideWithRAG({
sceneText: combinedSceneText,
characterName: characterName.trim(),
productionTitle: productionTitle.trim(),
productionType: productionType.trim(),
extractionMethod: allUploadData[0].extractionMethod,
hasFullScript: hasFullScript,
uploadData: allUploadData,
});
const guideContent = wrapGuideHtml(guideContentRaw, {
characterName: characterName.trim(),
productionTitle: productionTitle.trim(),
productionType: productionType.trim(),
});
console.log(`✅ Corey Ralston RAG Guide Complete!`);
// Save guide to database
try {
const GuideModel = Guide || require("./models/Guide");
const generatedGuideId = `corey_rag_${Date.now()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
const baseGuidePayload = {
guideId: generatedGuideId,
userId: currentUser.id,
characterName: characterName.trim(),
productionTitle: productionTitle.trim(),
productionType: productionType.trim(),
roleSize: roleSize || "Supporting",
genre: genre || "Drama",
storyline: storyline || "",
characterBreakdown: characterBreakdown || "",
callbackNotes: callbackNotes || "",
focusArea: focusArea || "",
sceneText: combinedSceneText,
generatedHtml: guideContent,
childGuideRequested: childGuideRequested || false,
childGuideCompleted: false,
};
let persistedGuide = null;
let persistenceMethod = "sequelize";
if (GuideModel) {
persistedGuide = await GuideModel.create(baseGuidePayload);
} else {
persistenceMethod = "supabase";
// Generate UUID for Supabase (it doesn't auto-generate like Sequelize)
const { randomUUID } = require("crypto");
baseGuidePayload.id = randomUUID();
// Ensure user exists in Supabase Users table (for foreign key constraint)
const userEnsured = await ensureSupabaseUser(currentUser);
if (!userEnsured) {
console.error(
"❌ Failed to ensure user exists in Supabase Users table"
);
throw new Error(
"Supabase Users table is not configured for guide saving"
);
}
persistedGuide = await supabaseInsertGuide(baseGuidePayload, {
user: currentUser,
});
if (!persistedGuide) {
throw new Error(
"Guide model unavailable and Supabase fallback failed"
);
}
}
console.log(
`💾 Guide saved via ${persistenceMethod} with ID: ${persistedGuide.id}`
);
if (
!hasUnlimitedPlan &&
userGuideLimit !== null &&
userGuideLimit > 0 &&
typeof currentUser.increment === "function"
) {
await currentUser.increment("guidesUsed").catch((err) => {
console.error(
"Failed to increment guide usage:",
err?.message || err
);
});
}
let childGuideQueued = false;
let childGuideCompleted = false;
if (childGuideRequested) {
childGuideQueued = true;
// In Vercel serverless, generate child guide SYNCHRONOUSLY before sending response
// Fire-and-forget doesn't work because the function terminates after res.json()
if (process.env.VERCEL) {
console.log(
`🌟 Generating child guide synchronously for ${persistedGuide.id} (Vercel mode)`
);
try {
const childResult = await generateChildGuideAsync({
guideId: persistedGuide.id,
childData: {
sceneText: combinedSceneText,
characterName: characterName.trim(),
productionTitle: productionTitle.trim(),
productionType: productionType.trim(),
parentGuideContent: guideContentRaw,
extractionMethod: allUploadData[0].extractionMethod,
},
userId: currentUser?.id,
});
if (childResult.success) {
childGuideCompleted = true;
console.log(`✅ Child guide completed for ${persistedGuide.id}`);
} else {
console.error(`❌ Child guide failed: ${childResult.error}`);
}
} catch (childErr) {
console.error(`❌ Child guide error:`, childErr);
}
} else {
// Non-Vercel: queue for background processing
queueChildGuideGeneration({
guideId: persistedGuide.id,
childData: {
sceneText: combinedSceneText,
characterName: characterName.trim(),
productionTitle: productionTitle.trim(),
productionType: productionType.trim(),
parentGuideContent: guideContentRaw,
extractionMethod: allUploadData[0].extractionMethod,
},
userId: currentUser?.id,
});
}
}
// Log the response being sent
const responseData = {
success: true,
guideId: persistedGuide.guideId,
guideContent,
childGuideRequested: !!childGuideRequested,
childGuideQueued,
childGuideCompleted: childGuideCompleted,
childGuideMessage: childGuideCompleted
? "Child guide generated successfully!"
: childGuideQueued
? "Child guide is being generated in the background."
: childGuideRequested
? "Child guide requested but queue unavailable."
: null,
generatedAt: new Date(),
savedToDatabase: true,
metadata: {
characterName,
productionTitle,
productionType,
scriptWordCount: combinedWordCount,
guideLength: guideContent.length,
childGuideStatus: childGuideCompleted
? "completed"
: childGuideQueued
? "queued"
: childGuideRequested
? "pending"
: "not_requested",
model: "claude-sonnet-4-20250514",
ragEnabled: true,
methodologyFiles: Object.keys(methodologyDatabase).length,
contentQuality: "corey-ralston-methodology-enhanced",
fileCount: uploadIdList.length,
uploadedFiles: uploadIdList.map((id) => uploads[id].filename),
},
};
console.log(`🌟 Sending response to frontend:`, {
childGuideRequested: responseData.childGuideRequested,
childGuideQueued: responseData.childGuideQueued,
childGuideCompleted: responseData.childGuideCompleted,
});
res.json(responseData);
} catch (dbError) {
console.error("❌ Database save error:", dbError);
// Check if it's an authentication error
if (
dbError.message.includes("Authentication required") ||
dbError.message.includes("User not found")
) {
return res.status(401).json({
success: false,
error: "Authentication required to save guide",
message: "Please log in to save your guide to your account",
guideContent: guideContent, // Still provide the guide content
generatedAt: new Date(),
savedToDatabase: false,
});
}
// Still return the guide content even if save fails for other reasons
res.json({
success: true,
guideId: `corey_rag_${uploadIdList[0] || uploadId}`,
guideContent: guideContent,
generatedAt: new Date(),
savedToDatabase: false,
saveError: dbError.message,
metadata: {
characterName,
productionTitle,
productionType,
scriptWordCount: combinedWordCount,
guideLength: guideContent.length,
model: "claude-sonnet-4-20250514",
ragEnabled: true,
methodologyFiles: Object.keys(methodologyDatabase).length,
contentQuality: "corey-ralston-methodology-enhanced",
fileCount: uploadIdList.length,
uploadedFiles: uploadIdList.map((id) => uploads[id].filename),
},
});
}
} catch (error) {
console.error("❌ Corey Ralston RAG error:", error);
// Always surface the server-side reason for easier client debug (no secrets)
res.status(500).json({
error:
"Failed to generate Corey Ralston methodology guide. Please try again.",
reason: error && error.message ? String(error.message) : undefined,
});
}
});
// Methodology API endpoint to view loaded files
app.get("/api/methodology", (req, res) => {
const summary = Object.values(methodologyDatabase).map((file) => ({
filename: file.filename,
type: file.type,
size: file.size,
keywords: file.keywords,
}));
res.json({
totalFiles: Object.keys(methodologyDatabase).length,
files: summary,
ragEnabled: true,
message: "Corey Ralston methodology files loaded and ready for RAG",
});
});
// Note: Guide endpoints are now handled by the mounted routes in ./routes/guides.js
// Generate child guide on-demand (for serverless environments where background tasks don't complete)
app.post("/api/guides/:id/generate-child", auth, async (req, res) => {
try {
const { id } = req.params;
const currentUser =
req.user || (User && req.userId ? await User.findByPk(req.userId) : null);
if (!currentUser) {
return res.status(401).json({ error: "Authentication required" });
}
let guideRecord = null;
let GuideModel = null;
try {
GuideModel = Guide || require("./models/Guide");
} catch (_) {
GuideModel = null;
}
if (GuideModel) {
guideRecord = await GuideModel.findOne({
where: { id, userId: currentUser.id },
});
} else if (isSupabaseAdminConfigured()) {
guideRecord = await supabaseFetchGuide({ id, userId: currentUser.id });
} else {
return res.status(503).json({ error: "Guide storage unavailable" });
}
if (!guideRecord) {
return res.status(404).json({ error: "Guide not found" });
}
// Check if child guide was requested but not completed
if (!guideRecord.childGuideRequested) {
return res
.status(400)
.json({ error: "Child guide was not requested for this guide" });
}
if (guideRecord.childGuideCompleted && guideRecord.childGuideHtml) {
return res.json({
success: true,
message: "Child guide already exists",
alreadyCompleted: true,
});
}
console.log(`🌟 On-demand child guide generation for guide ${id}`);
const result = await generateChildGuideAsync({
guideId: id,
childData: {
sceneText: guideRecord.sceneText,
characterName: guideRecord.characterName,
productionTitle: guideRecord.productionTitle,
productionType: guideRecord.productionType,
parentGuideContent: guideRecord.generatedHtml,
extractionMethod: "stored",
},
userId: currentUser.id,
});
if (result.success) {
res.json({
success: true,
message: "Child guide generated successfully",
childGuideCompleted: true,
});
} else {
res.status(500).json({
success: false,
error: result.error || "Failed to generate child guide",
});
}
} catch (error) {
console.error("❌ Child guide generation error:", error);
res.status(500).json({ error: "Failed to generate child guide" });
}
});
// Promo Code Redemption (Supabase-based)
app.post("/api/promo-codes/redeem", auth, async (req, res) => {
try {
const { code } = req.body;
const userId = req.userId;
if (!code) {
return res
.status(400)
.json({ success: false, message: "Promo code is required" });
}
console.log(
`🎟️ Promo code redemption attempt - Code: ${code}, User: ${userId}`
);
if (!isSupabaseAdminConfigured()) {
return res
.status(503)
.json({ success: false, message: "Promo code service unavailable" });
}
// Find the promo code in Supabase
const { data: promoCode, error: promoError } = await runAdminQuery(
(client) =>
client
.from("PromoCodes")
.select("*")
.eq("code", code.toUpperCase())
.eq("isActive", true)
.maybeSingle()
);
if (promoError || !promoCode) {
console.log(`❌ Promo code not found: ${code}`);
return res
.status(404)
.json({ success: false, message: "Invalid promo code" });
}
// Check if expired
if (promoCode.expiresAt && new Date(promoCode.expiresAt) < new Date()) {
return res
.status(400)
.json({ success: false, message: "Promo code has expired" });
}
// Check max redemptions
if (
promoCode.maxRedemptions &&
promoCode.currentRedemptions >= promoCode.maxRedemptions
) {
return res.status(400).json({
success: false,
message: "Promo code has reached maximum redemptions",
});
}
// Check if user already redeemed this code
const { data: existingRedemption } = await runAdminQuery((client) =>
client
.from("PromoCodeRedemptions")
.select("id")
.eq("promoCodeId", promoCode.id)
.eq("userId", userId)
.maybeSingle()
);
if (existingRedemption) {
return res.status(400).json({
success: false,
message: "You have already redeemed this code",
});
}
// Create redemption record
const { error: redemptionError } = await runAdminQuery((client) =>
client.from("PromoCodeRedemptions").insert({
id: require("crypto").randomUUID(),
promoCodeId: promoCode.id,
userId: userId,
guidesGranted: promoCode.guidesGranted || 1,
discountPercent: promoCode.discountPercent || 0,
redeemedAt: new Date().toISOString(),
})
);
if (redemptionError) {
console.error("❌ Failed to create redemption record:", redemptionError);
// Continue anyway - the important part is granting the guides
}
// Increment promo code redemption count
await runAdminQuery((client) =>
client
.from("PromoCodes")
.update({ currentRedemptions: (promoCode.currentRedemptions || 0) + 1 })
.eq("id", promoCode.id)
);
// Grant guides to user - update their guidesLimit
const { data: currentUser } = await runAdminQuery((client) =>
client.from("Users").select("guidesLimit").eq("id", userId).maybeSingle()
);
const newLimit =
(currentUser?.guidesLimit || 0) + (promoCode.guidesGranted || 1);
await runAdminQuery((client) =>
client.from("Users").update({ guidesLimit: newLimit }).eq("id", userId)
);
console.log(
`🎉 Promo code redeemed - Code: ${code}, User: ${userId}, Guides granted: ${
promoCode.guidesGranted || 1
}`
);
res.json({
success: true,
message: `Promo code redeemed! You received ${
promoCode.guidesGranted || 1
} free guide${(promoCode.guidesGranted || 1) > 1 ? "s" : ""}!`,
redemption: {
guidesGranted: promoCode.guidesGranted || 1,
redeemedAt: new Date().toISOString(),
},
});
} catch (error) {
console.error("❌ Promo code redemption error:", error);
res
.status(500)
.json({ success: false, message: "Failed to redeem promo code" });
}
});
// Download guide as PDF
app.get("/api/guides/:id/pdf", auth, async (req, res) => {
try {
const { id } = req.params;
const currentUser =
req.user || (User && req.userId ? await User.findByPk(req.userId) : null);
if (!currentUser) {
console.log("❌ PDF endpoint - Authenticated user not found");
return res.status(401).json({ error: "Authentication required" });
}
let guideRecord = null;
let GuideModel = null;
try {
GuideModel = Guide || require("./models/Guide");
} catch (_) {
GuideModel = null;
}
if (GuideModel) {
guideRecord = await GuideModel.findOne({
where: { id, userId: currentUser.id },
attributes: [
"id",
"guideId",
"characterName",
"productionTitle",
"productionType",
"roleSize",
"genre",
"storyline",
"characterBreakdown",
"callbackNotes",
"focusArea",
"sceneText",
"generatedHtml",
"createdAt",
"viewCount",
],
});
} else if (isSupabaseAdminConfigured()) {
guideRecord = await supabaseFetchGuide({ id, userId: currentUser.id });
} else {
return res
.status(503)
.json({ error: "Guide storage unavailable - please try again later" });
}
if (!guideRecord) {
return res.status(404).json({ error: "Guide not found" });
}
const guide = guideRecord.dataValues ? guideRecord.dataValues : guideRecord;
console.log(
`📄 Generating PDF for guide: ${guide.characterName} - ${guide.productionTitle}`
);
// Use Adobe PDF Services to convert HTML to PDF
const {
ServicePrincipalCredentials,
PDFServices,
MimeType,
HTMLToPDFJob,
HTMLToPDFResult,
PageLayout,
HTMLToPDFParams,
} = require("@adobe/pdfservices-node-sdk");
// Load Adobe credentials from JSON file
const credentialsPath = "./pdfservices-api-credentials.json";
const credentialsData = JSON.parse(
fs.readFileSync(credentialsPath, "utf8")
);
// Create credentials instance
const credentials = new ServicePrincipalCredentials({
clientId: credentialsData.client_credentials.client_id,
clientSecret: credentialsData.client_credentials.client_secret,
});
// Create PDF Services instance
const pdfServices = new PDFServices({ credentials });
// Create HTML content with proper styling
const htmlContent = `
🎭 Audition Guide: ${guide.characterName}
Production Details
Production: ${guide.productionTitle}
Type: ${guide.productionType}
Role Size: ${guide.roleSize}
Genre: ${guide.genre}
Created: ${new Date(
guide.createdAt
).toLocaleDateString()}
Character Analysis
${
guide.storyline
? `
Storyline: ${guide.storyline}
`
: ""
}
${
guide.characterBreakdown
? `
Character Breakdown: ${guide.characterBreakdown}
`
: ""
}
${
guide.focusArea
? `
Focus Area: ${guide.focusArea}
`
: ""
}
Generated Guide
${guide.generatedHtml}
`;
// Create a temporary HTML file
const tempHtmlPath = `./temp_guide_${id}.html`;
fs.writeFileSync(tempHtmlPath, htmlContent);
// Create input asset from HTML file
const readStream = fs.createReadStream(tempHtmlPath);
const inputAsset = await pdfServices.upload({
readStream,
mimeType: MimeType.HTML,
});
// Create parameters for the job
const pageLayout = new PageLayout({
pageHeight: 11,
pageWidth: 8.5,
});
const params = new HTMLToPDFParams({
pageLayout,
includeHeaderFooter: false,
});
// Create and submit the job
const job = new HTMLToPDFJob({ inputAsset, params });
const pollingURL = await pdfServices.submit({ job });
// Wait for job completion and get result
const pdfServicesResponse = await pdfServices.getJobResult({
pollingURL,
resultType: HTMLToPDFResult,
});
// Get content from the resulting asset
const resultAsset = pdfServicesResponse.result.asset;
const streamAsset = await pdfServices.getContent({ asset: resultAsset });
// Set response headers for PDF download
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="guide_${guide.characterName}_${guide.productionTitle}.pdf"`
);
// Get content length safely
const contentLength = streamAsset.asset?.size || "unknown";
if (contentLength !== "unknown") {
res.setHeader("Content-Length", contentLength);
}
// Stream the PDF to the response
streamAsset.readStream.pipe(res);
// Clean up temporary files
setTimeout(() => {
try {
fs.unlinkSync(tempHtmlPath);
} catch (err) {
console.log("Could not delete temp HTML file:", err.message);
}
}, 5000);
} catch (error) {
console.error("❌ PDF generation error:", error);
res.status(500).json({ error: "Failed to generate PDF" });
}
});
// Note: Email endpoint is now handled by the mounted routes in ./routes/guides.js
// Test email configuration
// Health check
app.get("/api/health", (req, res) => {
res.json({
status: "running",
model: DEFAULT_CLAUDE_MODEL,
maxTokens: DEFAULT_CLAUDE_MAX_TOKENS,
ragEnabled: true,
methodologyFiles: Object.keys(methodologyDatabase).length,
coreyRalstonMethodology: true,
apiKey: ANTHROPIC_API_KEY ? "configured" : "missing",
anthropicKeyLen: ANTHROPIC_API_KEY ? ANTHROPIC_API_KEY.length : 0,
uploadsCount: Object.keys(uploads).length,
adobeExtract:
process.env.ADOBE_PDF_EXTRACT_ENABLED === "true" ? "enabled" : "disabled",
minExtractWords: parseInt(process.env.MIN_EXTRACT_WORDS || "200", 10),
extraction: {
adobeEnabled: process.env.ADOBE_PDF_EXTRACT_ENABLED === "true",
minExtractWords: parseInt(process.env.MIN_EXTRACT_WORDS || "200", 10),
},
extractionTotals: extractionStats.totals,
extractionLast: extractionStats.last,
features: [
"True RAG with Corey Ralston methodology",
"Intelligent methodology search",
"Example guide pattern matching",
"Professional coaching voice replication",
"Claude Sonnet 4 + 16K tokens",
"PREP101 authentic methodology",
"Actor Motivator writing style",
"User authentication & authorization",
"Stripe payment integration",
"Subscription management",
"Guide usage tracking",
],
message:
"PREP101 Corey Ralston RAG-Enhanced Guide Generator with Actor Motivator Style + Full Auth & Payment System",
});
});
// Fast health check for Railway (no database queries)
app.get("/api/health", (req, res) => {
res.status(200).json({
status: "healthy",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || "development",
server: "PREP101 Enhanced Backend",
});
});
// Even faster health check for Railway deployment
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
// Enhanced health check with new features (for detailed monitoring)
app.get("/health", (req, res) => {
res.json({
status: "healthy",
timestamp: new Date().toISOString(),
environment: config.server.env,
features: {
rag: true,
authentication: true,
payments: true,
guides: true,
uploads: true,
},
server: "PREP101 Enhanced Backend",
});
});
// Initialize server
const startServer = async () => {
try {
// Initialize database
await initializeDatabase();
// Load methodology files
loadMethodologyFiles();
// Start server
const PORT = process.env.PORT || 5001;
app.listen(PORT, "0.0.0.0", () => {
console.log("🎭 PREP101 COREY RALSTON RAG-ENHANCED GENERATOR");
console.log(`🚀 Server running on port ${PORT}`);
console.log(`🤖 Model: Claude Sonnet 4 ✅`);
console.log(`⚡ Max Tokens: 16,000 ✅`);
console.log(`🧠 RAG: Corey Ralston Methodology ✅`);
console.log(
`📚 Files Loaded: ${Object.keys(methodologyDatabase).length} ✅`
);
console.log(`🎯 Actor Motivator Style: ENABLED ✅`);
console.log("");
console.log("🎯 Corey Ralston RAG Features:");
console.log(" • True file-based RAG system");
console.log(" • Intelligent methodology search");
console.log(" • Example guide pattern matching");
console.log(" • Professional coaching voice replication");
console.log(" • PREP101 authentic methodology");
console.log(" • Actor Motivator writing style");
console.log("");
console.log("🔐 NEW: Authentication & Payment System");
console.log(" • User registration & login");
console.log(" • Stripe subscription management");
console.log(" • Guide usage tracking");
console.log(" • Subscription-based access control");
console.log("");
console.log(
"✅ Ready to generate authentic Corey Ralston guides with full auth & payments!"
);
});
} catch (error) {
console.error("❌ Failed to start server:", error);
process.exit(1);
}
};
// Initialize for serverless (Vercel)
const initializeForServerless = async () => {
try {
// Load methodology files immediately for serverless
loadMethodologyFiles();
console.log("🧠 Methodology files loaded for serverless");
} catch (error) {
console.error("❌ Failed to initialize for serverless:", error);
}
};
// Initialize immediately for serverless functions
initializeForServerless();
// For Vercel serverless functions, export the app instead of starting a server
module.exports = app;
// Only start server if not in Vercel environment
if (process.env.NODE_ENV !== "production" || !process.env.VERCEL) {
// Start the server
startServer();
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("🛑 SIGTERM received, shutting down gracefully");
await sequelize.close();
process.exit(0);
});
process.on("SIGINT", async () => {
console.log("🛑 SIGINT received, shutting down gracefully");
await sequelize.close();
process.exit(0);
});
}