Calender & Linked Added
This commit is contained in:
+4
-1
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user