790 lines
26 KiB
TypeScript
790 lines
26 KiB
TypeScript
import express from "express";
|
|
import path from "path";
|
|
import { createServer as createViteServer } from "vite";
|
|
import pg from "pg";
|
|
import dotenv from "dotenv";
|
|
import bcrypt from "bcryptjs";
|
|
import jwt from "jsonwebtoken";
|
|
import nodemailer from "nodemailer";
|
|
|
|
dotenv.config();
|
|
|
|
const app = express();
|
|
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
const JWT_SECRET = process.env.JWT_SECRET || "pinnacle_jwt_secret_key_2026_prod";
|
|
|
|
app.use(express.json({ limit: "50mb" }));
|
|
|
|
// Helper Functions for Password & Token Security
|
|
function hashPassword(password: string): string {
|
|
return bcrypt.hashSync(password, 10);
|
|
}
|
|
|
|
function verifyPassword(password: string, hashOrPlain?: string): boolean {
|
|
if (!hashOrPlain) return false;
|
|
if (hashOrPlain.startsWith("$2a$") || hashOrPlain.startsWith("$2b$")) {
|
|
return bcrypt.compareSync(password, hashOrPlain);
|
|
}
|
|
// Fallback for legacy case-insensitive or exact plain match, then upgrade
|
|
return password.toLowerCase() === hashOrPlain.toLowerCase();
|
|
}
|
|
|
|
function generateToken(user: any): string {
|
|
return jwt.sign(
|
|
{
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
fullName: user.fullName,
|
|
email: user.email,
|
|
dept: user.dept
|
|
},
|
|
JWT_SECRET,
|
|
{ expiresIn: "24h" }
|
|
);
|
|
}
|
|
|
|
// PostgreSQL Connection Pool Setup
|
|
let dbPool: pg.Pool | null = null;
|
|
let isPostgresConnected = false;
|
|
|
|
const dbUrl = process.env.DATABASE_URL ||
|
|
(process.env.POSTGRES_HOST
|
|
? `postgres://${process.env.POSTGRES_USER || 'postgres'}:${process.env.POSTGRES_PASSWORD || 'postgres_password'}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT || 5432}/${process.env.POSTGRES_DB || 'pinnacle_db'}`
|
|
: null);
|
|
|
|
if (dbUrl) {
|
|
try {
|
|
dbPool = new pg.Pool({
|
|
connectionString: dbUrl,
|
|
ssl: process.env.PGSSLMODE === "require" ? { rejectUnauthorized: false } : false,
|
|
connectionTimeoutMillis: 3000,
|
|
});
|
|
|
|
// Test DB connection and auto-create tables
|
|
dbPool.query(`
|
|
CREATE TABLE IF NOT EXISTS pinnacle_store (
|
|
id VARCHAR(50) PRIMARY KEY DEFAULT 'default',
|
|
data JSONB NOT NULL,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pinnacle_users (
|
|
id SERIAL PRIMARY KEY,
|
|
username VARCHAR(50) UNIQUE NOT NULL,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
|
|
full_name VARCHAR(100) NOT NULL,
|
|
email VARCHAR(100) NOT NULL,
|
|
dept VARCHAR(100) NOT NULL,
|
|
active BOOLEAN DEFAULT TRUE,
|
|
last_login TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`).then(() => {
|
|
isPostgresConnected = true;
|
|
console.log("Connected to PostgreSQL database successfully.");
|
|
}).catch(err => {
|
|
console.warn("PostgreSQL connection notice:", err.message, "(Falling back to memory storage for dev environment)");
|
|
});
|
|
} catch (err: any) {
|
|
console.warn("PostgreSQL Pool init notice:", err.message);
|
|
}
|
|
}
|
|
|
|
// In-Memory Fallback State (when PostgreSQL container is not connected)
|
|
let memoryStore: any = null;
|
|
|
|
// Auth Middleware to verify JWT tokens
|
|
const authenticateToken = (req: any, res: any, next: any) => {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: "Access denied. Authentication token missing." });
|
|
}
|
|
|
|
try {
|
|
const verified = jwt.verify(token, JWT_SECRET);
|
|
req.user = verified;
|
|
next();
|
|
} catch (err) {
|
|
return res.status(403).json({ error: "Invalid or expired authentication token." });
|
|
}
|
|
};
|
|
|
|
// API ROUTES
|
|
|
|
// Health Check
|
|
app.get("/api/health", async (req, res) => {
|
|
let pgStatus = "disconnected";
|
|
if (dbPool) {
|
|
try {
|
|
await dbPool.query("SELECT 1");
|
|
pgStatus = "connected";
|
|
isPostgresConnected = true;
|
|
} catch {
|
|
pgStatus = "error";
|
|
isPostgresConnected = false;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
status: "ok",
|
|
app: "Pinnacle Decision Portal",
|
|
postgres: pgStatus,
|
|
timestamp: new Date().toISOString(),
|
|
environment: process.env.NODE_ENV || "development",
|
|
});
|
|
});
|
|
|
|
// User Authentication: Login
|
|
app.post("/api/auth/login", async (req, res) => {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: "Username/email and password are required." });
|
|
}
|
|
|
|
let users = memoryStore?.users || [];
|
|
const term = String(username).trim().toLowerCase();
|
|
|
|
// Search user in memory store or default by username OR email
|
|
let user = users.find((u: any) =>
|
|
(u.username && u.username.toLowerCase() === term) ||
|
|
(u.email && u.email.toLowerCase() === term)
|
|
);
|
|
|
|
if (!user && (!memoryStore || !memoryStore.users || memoryStore.users.length === 0)) {
|
|
// Check initial defaults
|
|
const defaults = [
|
|
{ id: 1, username: 'admin', password: 'Admin', role: 'admin', fullName: 'System Administrator', email: 'admin@pinnacle.local', dept: 'Executive Management', active: true },
|
|
{ id: 2, username: 'editor', password: 'Editor', role: 'editor', fullName: 'Project Manager', email: 'pm@pinnacle.local', dept: 'Project Management Office', active: true },
|
|
{ id: 3, username: 'viewer', password: 'Viewer', role: 'viewer', fullName: 'Board Observer', email: 'observer@pinnacle.local', dept: 'Board of Directors', active: true }
|
|
];
|
|
user = defaults.find(u =>
|
|
(u.username && u.username.toLowerCase() === term) ||
|
|
(u.email && u.email.toLowerCase() === term)
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: "Invalid username/email or password." });
|
|
}
|
|
|
|
if (user.active === false) {
|
|
return res.status(403).json({ error: "Your account has been deactivated by an administrator." });
|
|
}
|
|
|
|
const isValid = verifyPassword(password, user.password);
|
|
|
|
if (!isValid) {
|
|
return res.status(401).json({ error: "Invalid username/email or password." });
|
|
}
|
|
|
|
// Upgrade password to hash if stored plain
|
|
if (!user.password.startsWith("$2a$") && !user.password.startsWith("$2b$")) {
|
|
user.password = hashPassword(password);
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
user.lastLogin = now;
|
|
|
|
const token = generateToken(user);
|
|
|
|
// Return user without sending sensitive hash back directly
|
|
const { password: _, ...userWithoutPassword } = user;
|
|
|
|
res.json({
|
|
success: true,
|
|
token,
|
|
mustChangePassword: !!user.mustChangePassword,
|
|
user: userWithoutPassword
|
|
});
|
|
});
|
|
|
|
// User Authentication: Session Verification
|
|
app.get("/api/auth/me", authenticateToken, (req: any, res: any) => {
|
|
res.json({
|
|
authenticated: true,
|
|
user: req.user
|
|
});
|
|
});
|
|
|
|
// User Authentication: Change Password
|
|
app.post("/api/auth/change-password", authenticateToken, (req: any, res: any) => {
|
|
const { currentPassword, newPassword } = req.body;
|
|
const username = req.user.username;
|
|
|
|
if (!currentPassword || !newPassword) {
|
|
return res.status(400).json({ error: "Current password and new password are required." });
|
|
}
|
|
|
|
if (newPassword.length < 6) {
|
|
return res.status(400).json({ error: "New password must be at least 6 characters long." });
|
|
}
|
|
|
|
let users = memoryStore?.users || [];
|
|
let user = users.find((u: any) => u.username.toLowerCase() === username.toLowerCase());
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: "User account not found." });
|
|
}
|
|
|
|
const isValid = verifyPassword(currentPassword, user.password);
|
|
if (!isValid) {
|
|
return res.status(401).json({ error: "Current password is incorrect." });
|
|
}
|
|
|
|
user.password = hashPassword(newPassword);
|
|
user.mustChangePassword = false;
|
|
|
|
res.json({ success: true, message: "Password updated successfully." });
|
|
});
|
|
|
|
// User Authentication: First Login Password Change
|
|
app.post("/api/auth/first-login-change-password", (req: any, res: any) => {
|
|
const { userId, username, newPassword } = req.body;
|
|
|
|
if (!newPassword || newPassword.length < 6) {
|
|
return res.status(400).json({ error: "New password must be at least 6 characters long." });
|
|
}
|
|
|
|
let users = memoryStore?.users || [];
|
|
let user = users.find((u: any) => u.id === userId || u.username.toLowerCase() === String(username).toLowerCase());
|
|
|
|
if (user) {
|
|
user.password = hashPassword(newPassword);
|
|
user.mustChangePassword = false;
|
|
}
|
|
|
|
res.json({ success: true, message: "Password changed successfully for first login." });
|
|
});
|
|
|
|
// User Authentication: Request Forgot Password
|
|
app.post("/api/auth/forgot-password", async (req: any, res: any) => {
|
|
const { identifier, resetCode } = req.body;
|
|
|
|
if (!identifier) {
|
|
return res.status(400).json({ error: "Identifier (username or email) is required." });
|
|
}
|
|
|
|
let users = memoryStore?.users || [];
|
|
let user = users.find((u: any) => u.username.toLowerCase() === identifier.toLowerCase() || u.email.toLowerCase() === identifier.toLowerCase());
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: "User account not found." });
|
|
}
|
|
|
|
// Send email if SMTP is configured
|
|
const transporter = createSmtpTransporter();
|
|
if (transporter) {
|
|
try {
|
|
await transporter.sendMail({
|
|
from: `"${backendSmtpConfig.fromName}" <${backendSmtpConfig.fromEmail}>`,
|
|
to: user.email,
|
|
subject: "Password Reset Request - Pinnacle Portal",
|
|
html: `
|
|
<div style="font-family: sans-serif; padding: 20px; color: #333;">
|
|
<h2>Password Reset Code</h2>
|
|
<p>Dear ${user.fullName},</p>
|
|
<p>You requested a password reset for your Pinnacle Executive Portal account.</p>
|
|
<p>Your 6-digit verification code is: <strong style="font-size: 20px; color: #f59e0b;">${resetCode}</strong></p>
|
|
<p>If you did not request this, please contact your administrator immediately.</p>
|
|
</div>
|
|
`
|
|
});
|
|
} catch (err) {
|
|
console.warn("SMTP send failed during forgot password request:", err);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, message: `Reset PIN generated and sent to ${user.email}` });
|
|
});
|
|
|
|
// User Authentication: Reset Password with Code
|
|
app.post("/api/auth/reset-password", (req: any, res: any) => {
|
|
const { username, newPassword } = req.body;
|
|
|
|
if (!newPassword || newPassword.length < 6) {
|
|
return res.status(400).json({ error: "New password must be at least 6 characters long." });
|
|
}
|
|
|
|
let users = memoryStore?.users || [];
|
|
const term = String(username || '').trim().toLowerCase();
|
|
let user = users.find((u: any) =>
|
|
(u.username && u.username.toLowerCase() === term) ||
|
|
(u.email && u.email.toLowerCase() === term)
|
|
);
|
|
|
|
if (user) {
|
|
user.password = hashPassword(newPassword);
|
|
user.mustChangePassword = false;
|
|
}
|
|
|
|
res.json({ success: true, message: "Password reset successfully." });
|
|
});
|
|
|
|
// Backend Secure SMTP Config Store
|
|
let backendSmtpConfig = {
|
|
host: process.env.SMTP_HOST || 'smtp.mailtrap.io',
|
|
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
|
user: process.env.SMTP_USER || '',
|
|
pass: process.env.SMTP_PASS || '',
|
|
secure: process.env.SMTP_SECURE === "true",
|
|
fromName: process.env.SMTP_FROM_NAME || 'Pinnacle Decision Portal',
|
|
fromEmail: process.env.SMTP_FROM_EMAIL || 'notifications@pinnacle.local',
|
|
enabled: true,
|
|
notifyOnNewDecision: true,
|
|
notifyOnHighRisk: true,
|
|
notifyOnUserCreated: true
|
|
};
|
|
|
|
function createSmtpTransporter(cfg?: any) {
|
|
const host = cfg?.host || backendSmtpConfig.host || process.env.SMTP_HOST;
|
|
const port = cfg?.port || backendSmtpConfig.port || parseInt(process.env.SMTP_PORT || "587", 10);
|
|
const user = cfg?.user || backendSmtpConfig.user || process.env.SMTP_USER;
|
|
const pass = (cfg?.pass && cfg.pass !== "••••••••••••") ? cfg.pass : (backendSmtpConfig.pass || process.env.SMTP_PASS);
|
|
const secure = cfg?.secure ?? backendSmtpConfig.secure;
|
|
|
|
if (!host || !user || !pass) {
|
|
return null;
|
|
}
|
|
|
|
return nodemailer.createTransport({
|
|
host,
|
|
port,
|
|
secure,
|
|
auth: { user, pass },
|
|
tls: { rejectUnauthorized: false }
|
|
});
|
|
}
|
|
|
|
// Backend SMTP Management API
|
|
app.get("/api/smtp/config", (req: any, res: any) => {
|
|
res.json({
|
|
host: backendSmtpConfig.host,
|
|
port: backendSmtpConfig.port,
|
|
user: backendSmtpConfig.user,
|
|
secure: backendSmtpConfig.secure,
|
|
fromName: backendSmtpConfig.fromName,
|
|
fromEmail: backendSmtpConfig.fromEmail,
|
|
enabled: backendSmtpConfig.enabled,
|
|
notifyOnNewDecision: backendSmtpConfig.notifyOnNewDecision,
|
|
notifyOnHighRisk: backendSmtpConfig.notifyOnHighRisk,
|
|
notifyOnUserCreated: backendSmtpConfig.notifyOnUserCreated,
|
|
hasPassword: Boolean(backendSmtpConfig.pass && backendSmtpConfig.pass.length > 0),
|
|
pass: backendSmtpConfig.pass ? "••••••••••••" : ""
|
|
});
|
|
});
|
|
|
|
app.post("/api/smtp/config", (req: any, res: any) => {
|
|
const { host, port, user, pass, secure, fromName, fromEmail, enabled, notifyOnNewDecision, notifyOnHighRisk, notifyOnUserCreated } = req.body;
|
|
|
|
let updatedPass = backendSmtpConfig.pass;
|
|
if (pass && pass !== "••••••••••••") {
|
|
updatedPass = pass;
|
|
}
|
|
|
|
backendSmtpConfig = {
|
|
host: host ?? backendSmtpConfig.host,
|
|
port: parseInt(port, 10) || backendSmtpConfig.port,
|
|
user: user ?? backendSmtpConfig.user,
|
|
pass: updatedPass,
|
|
secure: Boolean(secure),
|
|
fromName: fromName ?? backendSmtpConfig.fromName,
|
|
fromEmail: fromEmail ?? backendSmtpConfig.fromEmail,
|
|
enabled: enabled !== undefined ? Boolean(enabled) : backendSmtpConfig.enabled,
|
|
notifyOnNewDecision: notifyOnNewDecision !== undefined ? Boolean(notifyOnNewDecision) : backendSmtpConfig.notifyOnNewDecision,
|
|
notifyOnHighRisk: notifyOnHighRisk !== undefined ? Boolean(notifyOnHighRisk) : backendSmtpConfig.notifyOnHighRisk,
|
|
notifyOnUserCreated: notifyOnUserCreated !== undefined ? Boolean(notifyOnUserCreated) : backendSmtpConfig.notifyOnUserCreated
|
|
};
|
|
|
|
return res.json({
|
|
success: true,
|
|
message: "Backend SMTP configuration updated securely.",
|
|
config: {
|
|
...backendSmtpConfig,
|
|
pass: backendSmtpConfig.pass ? "••••••••••••" : ""
|
|
}
|
|
});
|
|
});
|
|
|
|
// SMTP Test Endpoint
|
|
app.post("/api/notifications/smtp-test", async (req: any, res: any) => {
|
|
const { recipientEmail, host, port, user, pass, secure, fromName, fromEmail } = req.body;
|
|
|
|
const targetRecipient = recipientEmail || "admin@pinnacle.local";
|
|
const activeCfg = {
|
|
host: host || backendSmtpConfig.host,
|
|
port: port || backendSmtpConfig.port,
|
|
user: user || backendSmtpConfig.user,
|
|
pass: (pass && pass !== "••••••••••••") ? pass : backendSmtpConfig.pass,
|
|
secure: secure !== undefined ? secure : backendSmtpConfig.secure,
|
|
fromName: fromName || backendSmtpConfig.fromName,
|
|
fromEmail: fromEmail || backendSmtpConfig.fromEmail
|
|
};
|
|
|
|
const senderName = activeCfg.fromName;
|
|
const senderEmail = activeCfg.fromEmail;
|
|
|
|
const transporter = createSmtpTransporter(activeCfg);
|
|
|
|
const mailOptions = {
|
|
from: `"${senderName}" <${senderEmail}>`,
|
|
to: targetRecipient,
|
|
subject: "Pinnacle Portal - SMTP Test Notification",
|
|
html: `
|
|
<div style="font-family: Arial, sans-serif; background: #0f172a; color: #f8fafc; padding: 24px; borderRadius: 12px;">
|
|
<h2 style="color: #f59e0b; margin-top: 0;">Pinnacle Executive Decision Portal</h2>
|
|
<p>This is a test notification confirming that your <strong>Backend SMTP Server Configuration</strong> is active and connected.</p>
|
|
<hr style="border-color: #334155;" />
|
|
<ul style="font-size: 13px; color: #94a3b8; line-height: 1.6;">
|
|
<li><strong>SMTP Host:</strong> ${activeCfg.host}</li>
|
|
<li><strong>Port:</strong> ${activeCfg.port}</li>
|
|
<li><strong>Sender:</strong> ${senderName} <${senderEmail}></li>
|
|
<li><strong>Timestamp:</strong> ${new Date().toISOString()}</li>
|
|
</ul>
|
|
<p style="font-size: 11px; color: #64748b; margin-bottom: 0;">Automated System Dispatch - Pinnacle Governance Engine</p>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
if (!transporter) {
|
|
return res.json({
|
|
success: true,
|
|
mode: "simulated",
|
|
message: `Simulated Email Dispatch: Notification preview created for ${targetRecipient}. (Fill Host, User & Password on backend SMTP settings to route via live server).`,
|
|
details: mailOptions
|
|
});
|
|
}
|
|
|
|
try {
|
|
const info = await transporter.sendMail(mailOptions);
|
|
return res.json({
|
|
success: true,
|
|
mode: "live_smtp",
|
|
message: `Email notification sent successfully to ${targetRecipient}! (Message ID: ${info.messageId})`,
|
|
messageId: info.messageId
|
|
});
|
|
} catch (err: any) {
|
|
console.error("SMTP Delivery Error:", err.message);
|
|
return res.status(500).json({
|
|
success: false,
|
|
mode: "failed",
|
|
error: `SMTP Delivery Failed: ${err.message}. Please check host, port, user, or password credentials.`,
|
|
simulatedFallback: mailOptions
|
|
});
|
|
}
|
|
});
|
|
|
|
// General Email Dispatch Endpoint
|
|
app.post("/api/notifications/send-email", async (req: any, res: any) => {
|
|
const { recipient, subject, body, triggerEvent } = req.body;
|
|
|
|
if (!recipient || !subject) {
|
|
return res.status(400).json({ error: "Recipient and subject are required." });
|
|
}
|
|
|
|
if (!backendSmtpConfig.enabled) {
|
|
return res.json({
|
|
success: true,
|
|
status: "disabled",
|
|
message: "Email notifications engine is currently disabled in backend settings."
|
|
});
|
|
}
|
|
|
|
const senderName = backendSmtpConfig.fromName || "Pinnacle Executive Portal";
|
|
const senderEmail = backendSmtpConfig.fromEmail || "notifications@pinnacle.local";
|
|
|
|
const transporter = createSmtpTransporter();
|
|
|
|
const mailOptions = {
|
|
from: `"${senderName}" <${senderEmail}>`,
|
|
to: recipient,
|
|
subject: subject,
|
|
html: `
|
|
<div style="font-family: Arial, sans-serif; background: #0f172a; color: #f8fafc; padding: 24px; border-radius: 12px; max-width: 600px;">
|
|
<div style="border-bottom: 1px solid #334155; padding-bottom: 12px; margin-bottom: 16px;">
|
|
<h2 style="color: #f59e0b; margin: 0; font-size: 20px;">Pinnacle Executive Portal</h2>
|
|
<span style="font-size: 11px; color: #10b981; font-weight: bold; text-transform: uppercase;">Governance Alert • ${triggerEvent || 'NOTIFICATION'}</span>
|
|
</div>
|
|
<div style="font-size: 14px; line-height: 1.6; color: #e2e8f0; margin-bottom: 20px;">
|
|
${body || 'No message content provided.'}
|
|
</div>
|
|
<div style="border-top: 1px solid #334155; padding-top: 12px; font-size: 11px; color: #64748b;">
|
|
This message was dispatched by Pinnacle Portal User Access & Governance Engine at ${new Date().toLocaleString()}.
|
|
</div>
|
|
</div>
|
|
`
|
|
};
|
|
|
|
if (!transporter) {
|
|
return res.json({
|
|
success: true,
|
|
status: "simulated",
|
|
message: `Simulated notification created for ${recipient}.`,
|
|
log: {
|
|
id: Date.now(),
|
|
recipient,
|
|
subject,
|
|
status: "simulated",
|
|
timestamp: new Date().toISOString(),
|
|
triggerEvent: triggerEvent || "GENERAL_ALERT"
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
const info = await transporter.sendMail(mailOptions);
|
|
return res.json({
|
|
success: true,
|
|
status: "sent",
|
|
messageId: info.messageId,
|
|
message: `Email sent to ${recipient}`,
|
|
log: {
|
|
id: Date.now(),
|
|
recipient,
|
|
subject,
|
|
status: "sent",
|
|
timestamp: new Date().toISOString(),
|
|
triggerEvent: triggerEvent || "GENERAL_ALERT"
|
|
}
|
|
});
|
|
} catch (err: any) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
status: "failed",
|
|
error: err.message,
|
|
log: {
|
|
id: Date.now(),
|
|
recipient,
|
|
subject,
|
|
status: "failed",
|
|
error: err.message,
|
|
timestamp: new Date().toISOString(),
|
|
triggerEvent: triggerEvent || "GENERAL_ALERT"
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Real-time SSE Connection Pool for Instant Multi-User Synchronization
|
|
const sseClients = new Set<express.Response>();
|
|
|
|
function broadcastToClients(messageObj: any) {
|
|
const payload = `data: ${JSON.stringify(messageObj)}\n\n`;
|
|
sseClients.forEach((client) => {
|
|
try {
|
|
client.write(payload);
|
|
} catch {
|
|
sseClients.delete(client);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Real-time Event Stream Endpoint (SSE)
|
|
app.get("/api/realtime/stream", (req, res) => {
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.flushHeaders();
|
|
|
|
// Send initial connection confirmation
|
|
res.write(`data: ${JSON.stringify({ type: "connected", timestamp: new Date().toISOString() })}\n\n`);
|
|
|
|
sseClients.add(res);
|
|
|
|
req.on("close", () => {
|
|
sseClients.delete(res);
|
|
});
|
|
});
|
|
|
|
// Automated Real-Time Background Scheduler (Dispatches reminders & checks schedules)
|
|
setInterval(() => {
|
|
if (!memoryStore) return;
|
|
|
|
const now = new Date();
|
|
const timestamp = now.toISOString();
|
|
|
|
// Broadcast heart-beat pulse to maintain SSE connections & verify real-time state consistency
|
|
broadcastToClients({
|
|
type: "heartbeat",
|
|
activeConnections: sseClients.size,
|
|
timestamp
|
|
});
|
|
}, 15000);
|
|
|
|
// Load Database Store
|
|
app.get("/api/db", async (req, res) => {
|
|
|
|
if (dbPool && isPostgresConnected) {
|
|
try {
|
|
const result = await dbPool.query("SELECT data FROM pinnacle_store WHERE id = 'default' LIMIT 1");
|
|
if (result.rows.length > 0) {
|
|
return res.json({ source: "postgres", data: result.rows[0].data });
|
|
}
|
|
} catch (err: any) {
|
|
console.error("Error fetching from Postgres:", err.message);
|
|
}
|
|
}
|
|
|
|
return res.json({ source: memoryStore ? "memory" : "default", data: memoryStore });
|
|
});
|
|
|
|
// Save Database Store
|
|
app.post("/api/db", async (req, res) => {
|
|
const payload = req.body;
|
|
if (!payload || typeof payload !== "object") {
|
|
return res.status(400).json({ error: "Invalid payload" });
|
|
}
|
|
|
|
// Pre-hash any plain passwords in the store payload for security
|
|
if (Array.isArray(payload.users)) {
|
|
payload.users = payload.users.map((u: any) => {
|
|
if (u.password && !u.password.startsWith("$2a$") && !u.password.startsWith("$2b$")) {
|
|
return { ...u, password: hashPassword(u.password) };
|
|
}
|
|
return u;
|
|
});
|
|
}
|
|
|
|
memoryStore = payload;
|
|
|
|
// Real-time broadcast to all connected clients & tabs
|
|
broadcastToClients({
|
|
type: "store_update",
|
|
data: payload,
|
|
updatedAt: new Date().toISOString()
|
|
});
|
|
|
|
if (dbPool && isPostgresConnected) {
|
|
try {
|
|
await dbPool.query(
|
|
`INSERT INTO pinnacle_store (id, data, updated_at)
|
|
VALUES ('default', $1, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (id)
|
|
DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP`,
|
|
[JSON.stringify(payload)]
|
|
);
|
|
return res.json({ success: true, savedTo: "postgres" });
|
|
} catch (err: any) {
|
|
console.error("Failed to persist to Postgres:", err.message);
|
|
return res.json({ success: true, savedTo: "memory", warning: "Failed to persist to Postgres" });
|
|
}
|
|
}
|
|
|
|
return res.json({ success: true, savedTo: "memory" });
|
|
});
|
|
|
|
// Deployment Configurations APIs for Coolify & Traefik
|
|
app.get("/api/deployment/dockerfile", (req, res) => {
|
|
res.type("text/plain").send(`FROM node:20-alpine AS builder
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm install
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
FROM node:20-alpine AS runner
|
|
WORKDIR /app
|
|
ENV NODE_ENV=production
|
|
ENV PORT=3000
|
|
COPY package*.json ./
|
|
RUN npm install --omit=dev
|
|
COPY --from=builder /app/dist ./dist
|
|
EXPOSE 3000
|
|
CMD ["node", "dist/server.cjs"]`);
|
|
});
|
|
|
|
app.get("/api/deployment/docker-compose", (req, res) => {
|
|
const domain = req.query.domain || "pinnacle.yourdomain.com";
|
|
res.type("text/plain").send(`version: '3.8'
|
|
|
|
services:
|
|
app:
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
container_name: pinnacle_app
|
|
restart: always
|
|
environment:
|
|
- PORT=3000
|
|
- NODE_ENV=production
|
|
- DATABASE_URL=postgres://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:-postgres_password}@postgres:5432/\${POSTGRES_DB:-pinnacle_db}
|
|
- POSTGRES_HOST=postgres
|
|
- POSTGRES_PORT=5432
|
|
- POSTGRES_USER=\${POSTGRES_USER:-postgres}
|
|
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD:-postgres_password}
|
|
- POSTGRES_DB=\${POSTGRES_DB:-pinnacle_db}
|
|
ports:
|
|
- "3000:3000"
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.http.routers.pinnacle-app.rule=Host(\`${domain}\`)"
|
|
- "traefik.http.routers.pinnacle-app.entrypoints=websecure"
|
|
- "traefik.http.routers.pinnacle-app.tls=true"
|
|
- "traefik.http.routers.pinnacle-app.tls.certresolver=letsencrypt"
|
|
- "traefik.http.services.pinnacle-app.loadbalancer.server.port=3000"
|
|
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
container_name: pinnacle_postgres
|
|
restart: always
|
|
environment:
|
|
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
|
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres_password}
|
|
POSTGRES_DB: \${POSTGRES_DB:-pinnacle_db}
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
|
ports:
|
|
- "5432:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-postgres} -d \${POSTGRES_DB:-pinnacle_db}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
volumes:
|
|
postgres_data:
|
|
driver: local`);
|
|
});
|
|
|
|
app.get("/api/deployment/schema.sql", (req, res) => {
|
|
res.type("text/plain").send(`-- Pinnacle Decision Portal PostgreSQL Schema
|
|
CREATE TABLE IF NOT EXISTS pinnacle_store (
|
|
id VARCHAR(50) PRIMARY KEY DEFAULT 'default',
|
|
data JSONB NOT NULL,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_pinnacle_store_updated ON pinnacle_store(updated_at);`);
|
|
});
|
|
|
|
// Serve frontend / Vite middleware
|
|
async function setupFrontend() {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
const vite = await createViteServer({
|
|
server: { middlewareMode: true },
|
|
appType: "spa",
|
|
});
|
|
app.use(vite.middlewares);
|
|
} else {
|
|
const distPath = path.join(process.cwd(), "dist");
|
|
app.use(express.static(distPath));
|
|
app.get("*", (req, res) => {
|
|
res.sendFile(path.join(distPath, "index.html"));
|
|
});
|
|
}
|
|
|
|
app.listen(PORT, "0.0.0.0", () => {
|
|
console.log(`Server listening on http://0.0.0.0:${PORT}`);
|
|
});
|
|
}
|
|
|
|
setupFrontend();
|