import { db } from '@/lib/db'; import nodemailer from 'nodemailer'; // ============================================ // NOTIFICATION CHANNELS // ============================================ /** * Send a message via Telegram Bot API with optional inline keyboard button. */ async function sendTelegram( botToken: string, chatId: string, text: string, buttonLabel?: string, buttonUrl?: string, ): Promise { try { const payload: Record = { chat_id: chatId, text, link_preview_options: { is_disabled: true }, }; // Add inline keyboard button for clickable URL (Telegram requires HTTPS) if (buttonLabel && buttonUrl && buttonUrl.startsWith('https://')) { payload.reply_markup = { inline_keyboard: [[{ text: buttonLabel, url: buttonUrl }]], }; } const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { const body = await res.text(); console.error('Telegram API error:', res.status, body); return false; } return true; } catch (err) { console.error('Telegram send failed:', err); return false; } } /** * Create a nodemailer SMTP transporter from environment variables. * Required env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD */ function createSmtpTransport() { const host = process.env.SMTP_HOST; const port = Number(process.env.SMTP_PORT || '587'); const user = process.env.SMTP_USER; const pass = process.env.SMTP_PASSWORD; if (!host || !user || !pass) return null; return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass }, }); } /** * Send an email notification via SMTP. * Requires SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD environment variables. * Falls back to logging if not configured. */ async function sendEmail(to: string, subject: string, html: string): Promise { const transporter = createSmtpTransport(); const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame '; if (!transporter) { console.warn('SMTP not configured — skipping email notification'); return false; } try { await transporter.sendMail({ from: fromAddress, to, subject, html }); return true; } catch (err) { console.error('Email send failed:', err); return false; } } // ============================================ // NOTIFICATION EVENT TYPES // ============================================ export type NotificationEvent = | { type: 'new_video'; projectName: string; videoTitle: string; addedBy: string; url: string } | { type: 'new_version'; projectName: string; videoTitle: string; versionLabel: string; addedBy: string; url: string } | { type: 'new_comment'; projectName: string; videoTitle: string; commentAuthor: string; commentText: string; timestamp: string; url: string } | { type: 'new_reply'; projectName: string; videoTitle: string; replyAuthor: string; replyText: string; parentAuthor: string; timestamp: string; url: string }; /** Structured Telegram message with text body + button label/URL */ interface TelegramMessage { text: string; buttonLabel: string; buttonUrl: string; } /** * Format a notification event into a Telegram message with an inline keyboard button. * The URL is no longer in the text body — it's attached as a clickable button instead. */ function formatTelegramMessage(event: NotificationEvent, timezone: string): TelegramMessage { const now = formatNow(timezone); switch (event.type) { case 'new_video': return { text: `đŸŽŦ New Video Added\n\n` + `▸ Project: ${event.projectName}\n` + `▸ Video: ${event.videoTitle}\n` + `▸ Added by: ${event.addedBy}\n` + `▸ ${now}`, buttonLabel: 'View Video', buttonUrl: event.url, }; case 'new_version': return { text: `đŸŽŦ New Version Added\n\n` + `▸ Project: ${event.projectName}\n` + `▸ Video: ${event.videoTitle}\n` + `▸ Version: ${event.versionLabel}\n` + `▸ Added by: ${event.addedBy}\n` + `▸ ${now}`, buttonLabel: 'View Version', buttonUrl: event.url, }; case 'new_comment': return { text: `đŸ’Ŧ New Comment\n\n` + `▸ Project: ${event.projectName}\n` + `▸ Video: ${event.videoTitle}\n` + `▸ By: ${event.commentAuthor} at ${event.timestamp}\n` + `▸ ${now}\n\n` + `"${truncate(event.commentText, 200)}"`, buttonLabel: 'View Comment', buttonUrl: event.url, }; case 'new_reply': return { text: `â†Šī¸ New Reply\n\n` + `▸ Project: ${event.projectName}\n` + `▸ Video: ${event.videoTitle}\n` + `▸ ${event.replyAuthor} replied to ${event.parentAuthor}\n` + `▸ ${now}\n\n` + `"${truncate(event.replyText, 200)}"`, buttonLabel: 'View Reply', buttonUrl: event.url, }; } } // ============================================ // EMAIL TEMPLATE // ============================================ const BASE_URL = () => process.env.NEXTAUTH_URL || ''; // Theme colors (hex equivalents of oklch dark theme) const COLORS = { bg: '#111114', // page background (very dark) card: '#1a1a20', // card background cardInner: '#212128', // inner card / section bg border: '#2a2a32', // subtle border accent: '#2ec8d8', // primary/accent teal-cyan accentDark: '#1a3a40',// accent background for headings text: '#ebebeb', // primary text textSecondary: '#9a9a9f', // muted text textDim: '#6a6a72', // dimmer labels } as const; /** * Wrap email body content in a branded template matching OpenFrame's dark theme. * Square corners (radius:0), card-based layout, teal accent, unsubscribe footer. */ function emailTemplate(body: string): string { const settingsUrl = `${BASE_URL()}/settings`; return `
OpenFrame
${body}

You received this because email notifications are enabled.

Unsubscribe · Manage notification settings
`; } /** Generates an info row for email detail tables */ function emailRow(label: string, value: string, isHighlight = false): string { const valStyle = isHighlight ? `color:${COLORS.text};font-weight:600;` : `color:${COLORS.textSecondary};`; return ` ${label} ${value} `; } /** Generates the accent-colored event type heading bar */ function emailHeading(icon: string, title: string): string { return ` ${icon}  ${title} `; } /** Generates a CTA button */ function emailButton(text: string, url: string): string { return `${text}`; } /** * Format a notification event into an email subject + full branded HTML email. */ function formatEmail(event: NotificationEvent, timezone: string): { subject: string; html: string } { const now = formatNow(timezone); switch (event.type) { case 'new_video': return { subject: `[OpenFrame] New video in ${event.projectName}: ${event.videoTitle}`, html: emailTemplate(` ${emailHeading('▶', 'New Video Added')} ${emailRow('Project', escapeHtml(event.projectName), true)} ${emailRow('Video', escapeHtml(event.videoTitle), true)} ${emailRow('Added by', escapeHtml(event.addedBy))} ${emailRow('When', now)}
${emailButton('View Video →', event.url)} `), }; case 'new_version': return { subject: `[OpenFrame] New version of ${event.videoTitle} in ${event.projectName}`, html: emailTemplate(` ${emailHeading('▶', 'New Version Added')} ${emailRow('Project', escapeHtml(event.projectName), true)} ${emailRow('Video', escapeHtml(event.videoTitle), true)} ${emailRow('Version', escapeHtml(event.versionLabel))} ${emailRow('Added by', escapeHtml(event.addedBy))} ${emailRow('When', now)}
${emailButton('View Version →', event.url)} `), }; case 'new_comment': return { subject: `[OpenFrame] New comment on ${event.videoTitle}`, html: emailTemplate(` ${emailHeading('●', 'New Comment')} ${emailRow('Project', escapeHtml(event.projectName), true)} ${emailRow('Video', escapeHtml(event.videoTitle), true)} ${emailRow('From', escapeHtml(event.commentAuthor))} ${emailRow('At', event.timestamp)} ${emailRow('When', now)}
${escapeHtml(truncate(event.commentText, 300))}
${emailButton('View Comment →', event.url)} `), }; case 'new_reply': return { subject: `[OpenFrame] ${event.replyAuthor} replied on ${event.videoTitle}`, html: emailTemplate(` ${emailHeading('↩', 'New Reply')} ${emailRow('Project', escapeHtml(event.projectName), true)} ${emailRow('Video', escapeHtml(event.videoTitle), true)} ${emailRow('From', `${escapeHtml(event.replyAuthor)} ${escapeHtml(event.parentAuthor)}`)} ${emailRow('When', now)}
${escapeHtml(truncate(event.replyText, 300))}
${emailButton('View Reply →', event.url)} `), }; } } /** * Generate branded HTML for test emails sent from settings page. */ export function testEmailHtml(): string { return emailTemplate(` ${emailHeading('✓', 'Test Notification')}

Email notifications are working.

You’ll receive emails when there’s activity on your projects.

`); } // ============================================ // MAIN DISPATCH // ============================================ /** * Notify the project owner about an event. * Looks up the owner's notification settings and dispatches to enabled channels. * Best-effort — never throws, logs errors. */ export async function notifyProjectOwner(ownerId: string, event: NotificationEvent): Promise { try { const settings = await db.notificationSetting.findUnique({ where: { userId: ownerId }, include: { user: { select: { email: true } } }, }); if (!settings) return; // No notification preferences configured const shouldNotify = (event.type === 'new_video' && settings.onNewVideo) || (event.type === 'new_version' && settings.onNewVersion) || (event.type === 'new_comment' && settings.onNewComment) || (event.type === 'new_reply' && settings.onNewReply); if (!shouldNotify) return; const promises: Promise[] = []; const tz = settings.timezone || 'UTC'; // Telegram if (settings.telegramEnabled && settings.telegramBotToken && settings.telegramChatId) { const msg = formatTelegramMessage(event, tz); promises.push(sendTelegram( settings.telegramBotToken, settings.telegramChatId, msg.text, msg.buttonLabel, msg.buttonUrl, )); } // Email if (settings.emailEnabled && settings.user.email) { const { subject, html } = formatEmail(event, tz); promises.push(sendEmail(settings.user.email, subject, html)); } await Promise.allSettled(promises); } catch (err) { console.error('Notification dispatch failed:', err); } } // ============================================ // HELPERS // ============================================ /** * Format current date/time in the user's timezone. * Returns e.g. "Jan 15, 2025 at 3:45 PM" */ function formatNow(timezone: string): string { try { return new Date().toLocaleString('en-US', { timeZone: timezone, month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } catch { // Invalid timezone — fall back to UTC return new Date().toLocaleString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** Escape a URL for use inside an HTML href="..." attribute */ function escapeAttr(str: string): string { return str .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function truncate(str: string, maxLen: number): string { return str.length > maxLen ? str.slice(0, maxLen) + '...' : str; }