mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The suite that landed in #43/#44 was written against existing behaviour, so a number of tests pinned bugs rather than asserting correct behaviour. This fixes the production code and moves each of those tests onto the fixed behaviour in the same change. Security: - project-download: derive the archive entry extension from the last path segment and restrict it to a short alphanumeric run, so an extensionless allowlisted url can no longer contribute a path separator; validate the r2 branch against the strict proxy-path pattern instead of a `startsWith`, which let `/api/upload/video/clip.mp4/../../etc/passwd` through verbatim. - rate-limit: hash a key or action wider than its column instead of skipping the query. Both the guard and the failing INSERT used to answer "allowed", so the limit stopped applying entirely. Warn at startup when TRUSTED_PROXY_MODE is unset in production. - video uploads: the file name decides the content type; a client-declared video mime no longer makes `payload.exe` acceptable. - email templates: escape in the helpers rather than relying on every caller, with an explicit `rawEmailHtml()` opt-out for the one call site that builds markup. `escapeHtml` now covers the single quote. - CSP: allow loopback object storage outside production only. - route-access: reach the billing redirect only for the workspace owner. Keying it off the owner's billing status alone made the redirect target an oracle for whose subscription had lapsed, and sent members to a page they cannot act on. - search: carry the same billing condition every other read path carries. - logger: check `err.name` as well as `err.constructor.name`, so a re-thrown, deserialised or minified Prisma error is still redacted. - upload tokens: resolve the signing secret outside the try, so a server booted without one fails loudly instead of reporting every grant as a forgery. - invitations: never downgrade an existing membership, and report a scoped invitation that points at nothing as not_found rather than accepted. - auth: resolve the workspace role for every signed-in caller, so checkProjectAccess and computeProjectAccess stop disagreeing about the owner who also owns the workspace. The `intent` option is gone with it. - r2-media-proxy: validate the object key inside the proxy so the guard travels with the function; delete the unused, unanchored `mediaUrlToR2Key`. - r2: sign the content type into presigned PUT grants. Correctness: - frame rate snapping picks the nearest standard, not the first within tolerance, so 24, 30 and 60 fps are reachable at all. - a version upload registers its Bunny cleanup as soon as bunny-init answers, so a failed tus upload no longer leaves a billed video behind. - deleting videos clears storage before the rows, so a refused DELETE leaves a retryable row rather than an orphaned object. - an expired upload session can be cancelled, which is what releases its quota. - `voice/` joins the delete allowlist, so a voice note can be removed by the module that wrote it. - a failed CORS write propagates instead of being mistaken for an empty config and replacing the bucket's rules. - filtering projects by workspace no longer hides projects the unfiltered call returns. - upload retries skip aborts and permanent 4xx; progress no longer divides by zero. - reply edits no longer clear the comment's tag; optimistic resolve rolls back to the state it replaced; the delete snapshot is captured once. - assorted UI fixes: duplicate React keys, double-click guards reading stale closures, the tag list fetched twice per load, a failed member list rendering as an empty one, a stale "Initializing upload..." beside a failure, and a registration banner pointing at an email that never arrives. Consistency and access: - the two download routes answer 404 for an id belonging to another tenant, as the comment export route already did. A caller who does belong still gets 403. - accessible names for the share-link password field, the guest name gates, the version dialog inputs and the comment-tag controls. Repository health: - the runner image installs production dependencies only. - a setup file for the unit project restores stubbed env centrally. - native tsconfig path resolution replaces vite-tsconfig-paths. - `uploadBytesWithProgress` exists once. - admin stats bill Bunny storage to the workspace owner like every other quota, gate on the configured flag, wire up the single-flight guard and count the statuses that belonged to no bucket. - `r2Client.destroy()` releases the presign client too. - `prepare` tolerates a production install, where husky is absent.
598 lines
21 KiB
TypeScript
598 lines
21 KiB
TypeScript
import { db } from '@/lib/db';
|
|
import nodemailer from 'nodemailer';
|
|
import {
|
|
EMAIL_COLORS,
|
|
brandedEmailTemplate,
|
|
emailButton,
|
|
emailHeading,
|
|
emailHighlight,
|
|
emailRow,
|
|
escapeHtml,
|
|
rawEmailHtml,
|
|
} from '@/lib/email-brand';
|
|
import { logError } from '@/lib/logger';
|
|
|
|
// ============================================
|
|
// 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<boolean> {
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
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) {
|
|
logError('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<boolean> {
|
|
const transporter = createSmtpTransport();
|
|
const fromAddress =
|
|
process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame <[email protected]>';
|
|
|
|
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) {
|
|
logError('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;
|
|
}
|
|
| {
|
|
type: 'approval_requested';
|
|
projectName: string;
|
|
videoTitle: string;
|
|
versionLabel: string;
|
|
requestedBy: string;
|
|
message?: string;
|
|
url: string;
|
|
}
|
|
| {
|
|
type: 'approval_action';
|
|
projectName: string;
|
|
videoTitle: string;
|
|
versionLabel: string;
|
|
actorName: string;
|
|
action: 'approved' | 'rejected';
|
|
note?: string;
|
|
url: string;
|
|
}
|
|
| {
|
|
type: 'approval_completed';
|
|
projectName: string;
|
|
videoTitle: string;
|
|
versionLabel: string;
|
|
approvedByCount: number;
|
|
url: string;
|
|
}
|
|
| {
|
|
type: 'approval_rejected';
|
|
projectName: string;
|
|
videoTitle: string;
|
|
versionLabel: string;
|
|
rejectedBy: string;
|
|
note?: 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,
|
|
};
|
|
case 'approval_requested':
|
|
return {
|
|
text:
|
|
`✅ Approval Requested\n\n` +
|
|
`▸ Project: ${event.projectName}\n` +
|
|
`▸ Video: ${event.videoTitle}\n` +
|
|
`▸ Version: ${event.versionLabel}\n` +
|
|
`▸ Requested by: ${event.requestedBy}\n` +
|
|
`▸ ${now}` +
|
|
(event.message ? `\n\n"${truncate(event.message, 200)}"` : ''),
|
|
buttonLabel: 'Review Request',
|
|
buttonUrl: event.url,
|
|
};
|
|
case 'approval_action':
|
|
return {
|
|
text:
|
|
`✅ Approval Update\n\n` +
|
|
`▸ Project: ${event.projectName}\n` +
|
|
`▸ Video: ${event.videoTitle}\n` +
|
|
`▸ Version: ${event.versionLabel}\n` +
|
|
`▸ ${event.actorName} ${event.action}\n` +
|
|
`▸ ${now}` +
|
|
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
|
|
buttonLabel: 'Open Request',
|
|
buttonUrl: event.url,
|
|
};
|
|
case 'approval_completed':
|
|
return {
|
|
text:
|
|
`✅ Approval Completed\n\n` +
|
|
`▸ Project: ${event.projectName}\n` +
|
|
`▸ Video: ${event.videoTitle}\n` +
|
|
`▸ Version: ${event.versionLabel}\n` +
|
|
`▸ Approved by: ${event.approvedByCount}\n` +
|
|
`▸ ${now}`,
|
|
buttonLabel: 'Open Version',
|
|
buttonUrl: event.url,
|
|
};
|
|
case 'approval_rejected':
|
|
return {
|
|
text:
|
|
`⛔ Approval Rejected\n\n` +
|
|
`▸ Project: ${event.projectName}\n` +
|
|
`▸ Video: ${event.videoTitle}\n` +
|
|
`▸ Version: ${event.versionLabel}\n` +
|
|
`▸ Rejected by: ${event.rejectedBy}\n` +
|
|
`▸ ${now}` +
|
|
(event.note ? `\n\n"${truncate(event.note, 200)}"` : ''),
|
|
buttonLabel: 'Open Request',
|
|
buttonUrl: event.url,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// EMAIL TEMPLATE
|
|
// ============================================
|
|
|
|
function emailTemplate(body: string): string {
|
|
const baseUrl = process.env.NEXTAUTH_URL || '';
|
|
return brandedEmailTemplate(body, {
|
|
footerText: 'You received this because email notifications are enabled.',
|
|
footerLinkText: 'Unsubscribe · Manage notification settings',
|
|
footerLinkUrl: `${baseUrl}/settings`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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(`
|
|
<tr>${emailHeading('▶', 'New Video Added')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('Added by', event.addedBy)}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
${emailButton('View Video →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'new_version':
|
|
return {
|
|
subject: `[OpenFrame] New version of ${event.videoTitle} in ${event.projectName}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('▶', 'New Version Added')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('Version', event.versionLabel)}
|
|
${emailRow('Added by', event.addedBy)}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
${emailButton('View Version →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'new_comment':
|
|
return {
|
|
subject: `[OpenFrame] New comment on ${event.videoTitle}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('●', 'New Comment')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('From', event.commentAuthor)}
|
|
${emailRow('At', event.timestamp)}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">
|
|
${escapeHtml(truncate(event.commentText, 300))}
|
|
</div>
|
|
${emailButton('View Comment →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'new_reply':
|
|
return {
|
|
subject: `[OpenFrame] ${event.replyAuthor} replied on ${event.videoTitle}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('↵', 'New Reply')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('From', rawEmailHtml(`<span style="color:${EMAIL_COLORS.text};font-weight:500;">${escapeHtml(event.replyAuthor)}</span> <span style="color:${EMAIL_COLORS.textDim};">→</span> ${escapeHtml(event.parentAuthor)}`))}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">
|
|
${escapeHtml(truncate(event.replyText, 300))}
|
|
</div>
|
|
${emailButton('View Reply →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'approval_requested':
|
|
return {
|
|
subject: `[OpenFrame] Approval requested for ${event.versionLabel} in ${event.projectName}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('✓', 'Approval Requested')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
${emailHighlight(`A new approval request is waiting for your response.`)}
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('Version', event.versionLabel)}
|
|
${emailRow('Requested by', event.requestedBy)}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
${event.message ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.message, 300))}</div>` : ''}
|
|
${emailButton('Review Request →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'approval_action':
|
|
return {
|
|
subject: `[OpenFrame] Approval ${event.action} by ${event.actorName}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('✓', 'Approval Update')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
${emailHighlight(`${event.actorName} ${event.action} this request.`)}
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('Version', event.versionLabel)}
|
|
${emailRow('Action', `${event.actorName} ${event.action}`)}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
${event.note ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.note, 300))}</div>` : ''}
|
|
${emailButton('Open Request →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'approval_completed':
|
|
return {
|
|
subject: `[OpenFrame] Approval completed for ${event.versionLabel}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('✓', 'Approval Completed')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
${emailHighlight(`All approvers accepted this request.`)}
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:20px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('Version', event.versionLabel)}
|
|
${emailRow('Approvals', String(event.approvedByCount))}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
${emailButton('Open Version →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
case 'approval_rejected':
|
|
return {
|
|
subject: `[OpenFrame] Approval rejected by ${event.rejectedBy}`,
|
|
html: emailTemplate(`
|
|
<tr>${emailHeading('⛔', 'Approval Rejected')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
${emailHighlight(`${event.rejectedBy} rejected this request.`)}
|
|
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
|
${emailRow('Project', event.projectName, true)}
|
|
${emailRow('Video', event.videoTitle, true)}
|
|
${emailRow('Version', event.versionLabel)}
|
|
${emailRow('Rejected by', event.rejectedBy)}
|
|
${emailRow('When', now)}
|
|
</table>
|
|
${event.note ? `<div style="border-left:2px solid #7aa7ff;padding:10px 14px;margin:0 0 20px;background-color:#2f2f2f;color:#c6c6cc;font-size:13px;line-height:1.6;">${escapeHtml(truncate(event.note, 300))}</div>` : ''}
|
|
${emailButton('Open Request →', event.url)}
|
|
</td></tr>
|
|
`),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate branded HTML for test emails sent from settings page.
|
|
*/
|
|
export function testEmailHtml(): string {
|
|
return emailTemplate(`
|
|
<tr>${emailHeading('✓', 'Test Notification')}</tr>
|
|
<tr><td style="padding:20px;">
|
|
<p style="margin:0 0 8px;font-size:14px;color:${EMAIL_COLORS.text};">Email notifications are working.</p>
|
|
<p style="margin:0;font-size:13px;color:${EMAIL_COLORS.textSecondary};">You’ll receive emails when there’s activity on your projects.</p>
|
|
</td></tr>
|
|
`);
|
|
}
|
|
|
|
// ============================================
|
|
// 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.
|
|
*/
|
|
function isApprovalEvent(event: NotificationEvent): boolean {
|
|
return (
|
|
event.type === 'approval_requested' ||
|
|
event.type === 'approval_action' ||
|
|
event.type === 'approval_completed' ||
|
|
event.type === 'approval_rejected'
|
|
);
|
|
}
|
|
|
|
function shouldSendEvent(
|
|
settings: {
|
|
onNewVideo: boolean;
|
|
onNewVersion: boolean;
|
|
onNewComment: boolean;
|
|
onNewReply: boolean;
|
|
onApprovalEvents: boolean;
|
|
},
|
|
event: NotificationEvent
|
|
): boolean {
|
|
if (event.type === 'new_video') return settings.onNewVideo;
|
|
if (event.type === 'new_version') return settings.onNewVersion;
|
|
if (event.type === 'new_comment') return settings.onNewComment;
|
|
if (event.type === 'new_reply') return settings.onNewReply;
|
|
if (isApprovalEvent(event)) return settings.onApprovalEvents;
|
|
return false;
|
|
}
|
|
|
|
export async function notifyUsers(userIds: string[], event: NotificationEvent): Promise<void> {
|
|
try {
|
|
const dedupedUserIds = Array.from(new Set(userIds.filter(Boolean)));
|
|
if (dedupedUserIds.length === 0) return;
|
|
|
|
const settingsList = await db.notificationSetting.findMany({
|
|
where: { userId: { in: dedupedUserIds } },
|
|
include: { user: { select: { email: true } } },
|
|
});
|
|
|
|
await Promise.allSettled(
|
|
settingsList.map(async (settings) => {
|
|
if (!shouldSendEvent(settings, event)) return;
|
|
|
|
const promises: Promise<boolean>[] = [];
|
|
const tz = settings.timezone || 'UTC';
|
|
|
|
const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
if (settings.telegramEnabled && telegramBotToken && settings.telegramChatId) {
|
|
const msg = formatTelegramMessage(event, tz);
|
|
promises.push(
|
|
sendTelegram(
|
|
telegramBotToken,
|
|
settings.telegramChatId,
|
|
msg.text,
|
|
msg.buttonLabel,
|
|
msg.buttonUrl
|
|
)
|
|
);
|
|
}
|
|
|
|
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) {
|
|
logError('Notification dispatch failed:', err);
|
|
}
|
|
}
|
|
|
|
export async function notifyProjectOwner(ownerId: string, event: NotificationEvent): Promise<void> {
|
|
try {
|
|
await notifyUsers([ownerId], event);
|
|
} catch (err) {
|
|
logError('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 truncate(str: string, maxLen: number): string {
|
|
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
|
|
}
|