Merge remote-tracking branch 'origin/main' into master

This commit is contained in:
2026-06-26 16:47:37 +02:00
82 changed files with 7954 additions and 1077 deletions
+25 -60
View File
@@ -1,76 +1,41 @@
import http from "http"
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
function parseUrl(url: string) {
const u = new URL(url)
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
}
export async function chatWithAI(message: string, jwtToken: string) {
const body = JSON.stringify({ message })
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
const text = await new Promise<string>((resolve, reject) => {
const req = http.request(
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
(res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data)
} else {
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
}
})
}
)
req.on("error", reject)
req.write(body)
req.end()
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }),
})
const data = JSON.parse(text)
if (!res.ok) {
const text = await res.text()
throw new Error(`AI service error (${res.status}): ${text}`)
}
const data = await res.json()
return data.response || ""
}
export async function checkAiServiceStatus() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try {
await new Promise<void>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
})
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
return true
} catch {
return false
}
}
export async function fetchJobs() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
try {
const text = await new Promise<string>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => resolve(data))
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
const data = JSON.parse(text)
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
if (!res.ok) return []
const data = await res.json()
return data.jobs || []
} catch {
console.warn("Failed to fetch AI jobs")
return []
}
}
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
if (!res.ok) return false
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+2 -24
View File
@@ -17,7 +17,6 @@ export interface SessionUser {
id: string;
username: string;
email: string;
phone: string | null;
firstName: string;
lastName: string;
role: string;
@@ -86,7 +85,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) {
const result = await query(
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active, u.avatar_url,
r.name AS role_name
FROM users u
@@ -186,7 +185,6 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string,
username: dbUser.username as string,
email: dbUser.email as string,
phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
@@ -194,27 +192,6 @@ export function mapDbUserToSessionUser(
};
}
export async function encryptPassword(password: string): Promise<string> {
const result = await query("SELECT encrypt_password($1) AS encrypted", [password]);
return result.rows[0]?.encrypted;
}
export async function decryptPassword(encrypted: string): Promise<string | null> {
try {
const result = await query("SELECT decrypt_password($1) AS decrypted", [encrypted]);
return result.rows[0]?.decrypted || null;
} catch {
return null;
}
}
export async function setSessionContext(userId: string, ip?: string) {
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
if (ip) {
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
}
}
export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role });
@@ -224,6 +201,7 @@ export async function createSession(userId: string, role: string) {
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
return token;
+91
View File
@@ -0,0 +1,91 @@
export const BLOCKED_CODE_EXTENSIONS = new Set([
"py", "pyc", "pyo", "pyw", "pyx", "ipynb",
"js", "mjs", "cjs", "jsx", "ts", "tsx",
"php", "phtml", "php3", "php4", "php5",
"rb", "erb",
"pl", "pm",
"lua",
"sh", "bash", "zsh", "ksh", "fish",
"ps1", "psm1", "psd1",
"bat", "cmd",
"vbs",
"applescript",
"ahk",
"au3",
"tcl",
"c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx",
"cs", "vb",
"java", "class", "jar",
"go",
"rs",
"swift",
"kt", "kts",
"scala",
"groovy", "gradle",
"d",
"nim",
"cr",
"zig",
"v",
"vhd", "vhdl",
"asm", "s",
"f", "f90", "f95", "for",
"pas", "pp",
"ada", "adb", "ads",
"cob", "cbl",
"hs", "lhs",
"ml", "mli",
"fs", "fsx", "fsi",
"clj", "cljs", "cljc", "edn",
"lisp", "lsp",
"scm", "ss",
"rkt",
"ex", "exs",
"erl", "hrl",
"jl",
"elm",
"purs",
"re", "rei",
"sol",
"move",
"pro",
"m",
"html", "htm",
"css", "scss", "sass", "less",
"vue", "svelte",
"json5",
"o", "so", "dll", "exe", "dylib",
"wasm", "wat", "bin",
"out",
"deb", "rpm",
"app", "apk", "ipa",
"sql", "psql",
"ino", "pde",
"sas", "do",
])
export function getAllExtensions(name: string): string[] {
const parts = name.split(".")
if (parts.length <= 1) return []
return parts.slice(1).map((ext) => ext.toLowerCase())
}
export function hasBlockedCodeExtension(name: string): boolean {
const exts = getAllExtensions(name)
return exts.some((ext) => BLOCKED_CODE_EXTENSIONS.has(ext))
}
export function filterBlockedFiles<T extends { name: string }>(
files: T[],
): { allowed: T[]; rejected: T[] } {
const allowed: T[] = []
const rejected: T[] = []
for (const f of files) {
if (hasBlockedCodeExtension(f.name)) {
rejected.push(f)
} else {
allowed.push(f)
}
}
return { allowed, rejected }
}
+4 -1
View File
@@ -16,9 +16,12 @@ pool.on("error", (err) => {
console.error("Unexpected database pool error:", err)
})
export async function query(text: string, params?: unknown[]) {
export async function query(text: string, params?: unknown[], userId?: string) {
const client = await pool.connect()
try {
if (userId) {
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
}
const result = await client.query(text, params)
return result
} finally {
+190
View File
@@ -0,0 +1,190 @@
import nodemailer from "nodemailer"
import { query } from "@/lib/db"
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || ""
const SMTP_HOST = process.env.SMTP_HOST || ""
const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587", 10)
const SMTP_USER = process.env.SMTP_USER || ""
const SMTP_PASS = process.env.SMTP_PASS || ""
const EMAIL_FROM = process.env.EMAIL_FROM || "noreply@coastit.co.za"
function createTransporter() {
const hasAuth = !!(SMTP_USER && SMTP_PASS)
const isLocal = SMTP_HOST === "127.0.0.1" || SMTP_HOST === "localhost"
const tlsOptions = hasAuth && !isLocal
? { rejectUnauthorized: true }
: { rejectUnauthorized: false }
return nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
requireTLS: hasAuth && !isLocal,
...(hasAuth ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
tls: tlsOptions,
connectionTimeout: 5000,
greetingTimeout: 5000,
})
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
weekday: "long", month: "long", day: "numeric", year: "numeric",
})
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString("en-US", {
hour: "numeric", minute: "2-digit", hour12: true,
})
}
function formatDuration(min: number) {
if (min < 60) return `${min} minutes`
const h = Math.floor(min / 60)
const m = min % 60
return m > 0 ? `${h}h ${m}m` : `${h} hour${h > 1 ? "s" : ""}`
}
export interface EventConfirmationInput {
creatorName: string
creatorEmail: string
participantName: string | null
participantEmail: string | null
title: string
description?: string
eventType: string
startTime: string
endTime?: string
durationMinutes?: number
}
function buildEmail(event: EventConfirmationInput, variant: "scheduled" | "rescheduled" = "scheduled") {
const dateStr = formatDate(event.startTime)
const timeStr = formatTime(event.startTime)
const durStr = event.durationMinutes ? formatDuration(event.durationMinutes) : ""
const typeLabel = event.eventType.charAt(0).toUpperCase() + event.eventType.slice(1)
const heading = variant === "rescheduled" ? "Event Rescheduled" : "Event Scheduled"
const badge = variant === "rescheduled"
? '<div style="background:#fef3c7;color:#92400e;font-size:12px;padding:6px 12px;border-radius:6px;margin-bottom:16px;display:inline-block">This event has been rescheduled</div>'
: ""
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
<div style="background:linear-gradient(135deg,#CC0000,#0033CC);padding:24px 32px">
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
</div>
<div style="padding:32px">
${badge}
<p style="font-size:15px;margin:0 0 16px"><strong>${event.title}</strong></p>
<table style="width:100%;font-size:13px;border-collapse:collapse">
<tr><td style="padding:6px 0;color:#666;width:90px">Type</td><td style="padding:6px 0">${typeLabel}</td></tr>
<tr><td style="padding:6px 0;color:#666">Date</td><td style="padding:6px 0">${dateStr}</td></tr>
<tr><td style="padding:6px 0;color:#666">Time</td><td style="padding:6px 0">${timeStr}</td></tr>
${durStr ? `<tr><td style="padding:6px 0;color:#666">Duration</td><td style="padding:6px 0">${durStr}</td></tr>` : ""}
${event.description ? `<tr><td style="padding:6px 0;color:#666;vertical-align:top">Notes</td><td style="padding:6px 0">${event.description}</td></tr>` : ""}
<tr><td style="padding:6px 0;color:#666">Created by</td><td style="padding:6px 0">${event.creatorName}</td></tr>
${event.participantName ? `<tr><td style="padding:6px 0;color:#666">With</td><td style="padding:6px 0">${event.participantName}</td></tr>` : ""}
</table>
<p style="font-size:11px;color:#999;margin-top:24px;padding-top:16px;border-top:1px solid #eee">Automated confirmation from CRM.</p>
</div>
</div>
</body></html>`
const text = `${variant === "rescheduled" ? "[RESCHEDULED] " : ""}Event: ${event.title}
Type: ${typeLabel}
Date: ${dateStr}
Time: ${timeStr}
${durStr ? `Duration: ${durStr}` : ""}
${event.description ? `Notes: ${event.description}` : ""}
Created by: ${event.creatorName}
${event.participantName ? `With: ${event.participantName}` : ""}`
const prefix = variant === "rescheduled" ? "🔄 Rescheduled: " : ""
const subject = `${prefix}${event.title}${dateStr}`
return { html, text, subject, dateStr }
}
export async function sendEventConfirmation(event: EventConfirmationInput) {
const { html, text, subject } = buildEmail(event)
const recipients: string[] = []
if (event.creatorEmail) recipients.push(event.creatorEmail)
if (event.participantEmail && !recipients.includes(event.participantEmail)) recipients.push(event.participantEmail)
if (ADMIN_EMAIL && !recipients.includes(ADMIN_EMAIL)) recipients.push(ADMIN_EMAIL)
// 1. Always store in database
for (const to of recipients) {
await query(
`INSERT INTO sent_emails (recipient, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)`,
[to, subject, html, text],
).catch(() => {})
}
if (recipients.length === 0) return
// 2. Send via SMTP if configured
if (SMTP_HOST) {
const transporter = createTransporter()
await transporter.sendMail({
from: EMAIL_FROM,
to: recipients.join(", "),
subject,
text,
html,
}).catch(() => {})
return
}
// 3. Fallback: log to console with instructions
console.log("=".repeat(60))
console.log(`📧 Email ready — ${subject}`)
console.log(` To: ${recipients.join(", ")}`)
console.log(` Body:\n${text}`)
console.log("=".repeat(60))
console.log("💡 To send real emails:")
console.log(" Fill in SMTP_HOST in .env (maildev works at localhost:1025 with no auth)")
console.log(" Or set SMTP_HOST/SMTP_USER/SMTP_PASS for authenticated relays")
}
export async function sendEventRescheduled(event: EventConfirmationInput) {
const { html, text, subject } = buildEmail(event, "rescheduled")
const recipients: string[] = []
if (event.creatorEmail) recipients.push(event.creatorEmail)
if (event.participantEmail && !recipients.includes(event.participantEmail)) recipients.push(event.participantEmail)
if (ADMIN_EMAIL && !recipients.includes(ADMIN_EMAIL)) recipients.push(ADMIN_EMAIL)
for (const to of recipients) {
await query(
`INSERT INTO sent_emails (recipient, subject, body_html, body_text)
VALUES ($1, $2, $3, $4)`,
[to, subject, html, text],
).catch(() => {})
}
if (recipients.length === 0) return
if (SMTP_HOST) {
const transporter = createTransporter()
await transporter.sendMail({
from: EMAIL_FROM,
to: recipients.join(", "),
subject,
text,
html,
}).catch(() => {})
return
}
console.log("=".repeat(60))
console.log(`🔄 Reschedule email ready — ${subject}`)
console.log(` To: ${recipients.join(", ")}`)
console.log(` Body:\n${text}`)
console.log("=".repeat(60))
}
+80
View File
@@ -0,0 +1,80 @@
export interface IcsEvent {
uid: string
title: string
description?: string
location?: string
startTime: Date
endTime?: Date
durationMinutes?: number
organizerName?: string
organizerEmail?: string
attendeeName?: string
attendeeEmail?: string
}
function formatIcsDate(d: Date): string {
return d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
}
function escapeIcsText(text: string): string {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n")
}
export function buildIcs(event: IcsEvent): string {
const now = formatIcsDate(new Date())
const dtStart = formatIcsDate(event.startTime)
let dtEnd: string
if (event.endTime) {
dtEnd = formatIcsDate(event.endTime)
} else if (event.durationMinutes) {
const end = new Date(event.startTime.getTime() + event.durationMinutes * 60000)
dtEnd = formatIcsDate(end)
} else {
const end = new Date(event.startTime.getTime() + 60 * 60000)
dtEnd = formatIcsDate(end)
}
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CRM//Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${event.uid}`,
`DTSTAMP:${now}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeIcsText(event.title)}`,
]
if (event.description) {
lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`)
}
if (event.organizerEmail) {
const cn = event.organizerName ? `;CN=${escapeIcsText(event.organizerName)}` : ""
lines.push(`ORGANIZER${cn}:mailto:${event.organizerEmail}`)
}
if (event.attendeeEmail) {
const cn = event.attendeeName ? `;CN=${escapeIcsText(event.attendeeName)}` : ""
lines.push(`ATTENDEE${cn};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:${event.attendeeEmail}`)
}
lines.push("STATUS:CONFIRMED")
lines.push("BEGIN:VALARM")
lines.push("TRIGGER:-PT15M")
lines.push("ACTION:DISPLAY")
lines.push(`DESCRIPTION:Reminder: ${escapeIcsText(event.title)}`)
lines.push("END:VALARM")
lines.push("END:VEVENT")
lines.push("END:VCALENDAR")
return lines.join("\r\n")
}