mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: add approvals workflow and unified member invitation management across projects, workspaces, and videos
This commit is contained in:
+206
-127
@@ -1,5 +1,14 @@
|
||||
import { db } from '@/lib/db';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
EMAIL_COLORS,
|
||||
brandedEmailTemplate,
|
||||
emailButton,
|
||||
emailHeading,
|
||||
emailHighlight,
|
||||
emailRow,
|
||||
escapeHtml,
|
||||
} from '@/lib/email-brand';
|
||||
|
||||
// ============================================
|
||||
// NOTIFICATION CHANNELS
|
||||
@@ -97,7 +106,11 @@ 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: '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 {
|
||||
@@ -160,6 +173,57 @@ function formatTelegramMessage(event: NotificationEvent, timezone: string): Tele
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,82 +231,13 @@ function formatTelegramMessage(event: NotificationEvent, timezone: string): Tele
|
||||
// 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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><meta name="color-scheme" content="dark"></head>
|
||||
<body style="margin:0;padding:0;background-color:${COLORS.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:${COLORS.text};">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:${COLORS.bg};padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr><td style="padding:0 0 24px;">
|
||||
<table cellpadding="0" cellspacing="0"><tr>
|
||||
<td style="padding-right:10px;vertical-align:middle;color:${COLORS.accent};font-size:20px;">▶</td>
|
||||
<td style="vertical-align:middle;font-size:18px;font-weight:700;color:${COLORS.text};letter-spacing:-0.3px;">OpenFrame</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
|
||||
<!-- Main Card -->
|
||||
<tr><td style="background-color:${COLORS.card};border:1px solid ${COLORS.border};padding:0;">
|
||||
${body}
|
||||
</td></tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding:20px 0 0;text-align:center;">
|
||||
<p style="margin:0 0 6px;font-size:11px;color:${COLORS.textDim};">You received this because email notifications are enabled.</p>
|
||||
<a href="${escapeAttr(settingsUrl)}" style="font-size:11px;color:${COLORS.accent};text-decoration:underline;">Unsubscribe · Manage notification settings</a>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** 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 `<tr>
|
||||
<td style="padding:6px 16px 6px 0;color:${COLORS.textDim};font-size:13px;white-space:nowrap;vertical-align:top;">${label}</td>
|
||||
<td style="padding:6px 0;font-size:13px;${valStyle}">${value}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
/** Generates the accent-colored event type heading bar */
|
||||
function emailHeading(icon: string, title: string): string {
|
||||
return `<td style="padding:16px 20px;border-bottom:1px solid ${COLORS.border};background-color:${COLORS.accentDark};">
|
||||
<span style="font-size:14px;font-weight:600;color:${COLORS.accent};">${icon} ${title}</span>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
/** Generates a CTA button */
|
||||
function emailButton(text: string, url: string): string {
|
||||
return `<a href="${escapeAttr(url)}" style="display:inline-block;padding:9px 22px;background-color:${COLORS.accent};color:#0f1114;font-size:13px;font-weight:600;text-decoration:none;letter-spacing:0.2px;">${text}</a>`;
|
||||
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`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +292,7 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
|
||||
${emailRow('At', event.timestamp)}
|
||||
${emailRow('When', now)}
|
||||
</table>
|
||||
<div style="border-left:2px solid ${COLORS.accent};padding:10px 14px;margin:0 0 20px;background-color:${COLORS.cardInner};color:${COLORS.textSecondary};font-size:13px;line-height:1.6;">
|
||||
<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)}
|
||||
@@ -313,16 +308,91 @@ function formatEmail(event: NotificationEvent, timezone: string): { subject: str
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
||||
${emailRow('Project', escapeHtml(event.projectName), true)}
|
||||
${emailRow('Video', escapeHtml(event.videoTitle), true)}
|
||||
${emailRow('From', `<span style="color:${COLORS.text};font-weight:500;">${escapeHtml(event.replyAuthor)}</span> <span style="color:${COLORS.textDim};">→</span> ${escapeHtml(event.parentAuthor)}`)}
|
||||
${emailRow('From', `<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 ${COLORS.accent};padding:10px 14px;margin:0 0 20px;background-color:${COLORS.cardInner};color:${COLORS.textSecondary};font-size:13px;line-height:1.6;">
|
||||
<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', escapeHtml(event.projectName), true)}
|
||||
${emailRow('Video', escapeHtml(event.videoTitle), true)}
|
||||
${emailRow('Version', escapeHtml(event.versionLabel))}
|
||||
${emailRow('Requested by', escapeHtml(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(`${escapeHtml(event.actorName)} ${escapeHtml(event.action)} this request.`)}
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
||||
${emailRow('Project', escapeHtml(event.projectName), true)}
|
||||
${emailRow('Video', escapeHtml(event.videoTitle), true)}
|
||||
${emailRow('Version', escapeHtml(event.versionLabel))}
|
||||
${emailRow('Action', escapeHtml(`${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', escapeHtml(event.projectName), true)}
|
||||
${emailRow('Video', escapeHtml(event.videoTitle), true)}
|
||||
${emailRow('Version', escapeHtml(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(`${escapeHtml(event.rejectedBy)} rejected this request.`)}
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;margin-bottom:16px;">
|
||||
${emailRow('Project', escapeHtml(event.projectName), true)}
|
||||
${emailRow('Video', escapeHtml(event.videoTitle), true)}
|
||||
${emailRow('Version', escapeHtml(event.versionLabel))}
|
||||
${emailRow('Rejected by', escapeHtml(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>
|
||||
`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,8 +403,8 @@ 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:${COLORS.text};">Email notifications are working.</p>
|
||||
<p style="margin:0;font-size:13px;color:${COLORS.textSecondary};">You’ll receive emails when there’s activity on your projects.</p>
|
||||
<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>
|
||||
`);
|
||||
}
|
||||
@@ -348,44 +418,70 @@ export function testEmailHtml(): string {
|
||||
* 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<void> {
|
||||
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 settings = await db.notificationSetting.findUnique({
|
||||
where: { userId: ownerId },
|
||||
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 } } },
|
||||
});
|
||||
|
||||
if (!settings) return; // No notification preferences configured
|
||||
await Promise.allSettled(settingsList.map(async (settings) => {
|
||||
if (!shouldSendEvent(settings, event)) return;
|
||||
|
||||
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);
|
||||
const promises: Promise<boolean>[] = [];
|
||||
const tz = settings.timezone || 'UTC';
|
||||
|
||||
if (!shouldNotify) return;
|
||||
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,
|
||||
));
|
||||
}
|
||||
|
||||
const promises: Promise<boolean>[] = [];
|
||||
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,
|
||||
));
|
||||
}
|
||||
if (settings.emailEnabled && settings.user.email) {
|
||||
const { subject, html } = formatEmail(event, tz);
|
||||
promises.push(sendEmail(settings.user.email, subject, html));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
export async function notifyProjectOwner(ownerId: string, event: NotificationEvent): Promise<void> {
|
||||
try {
|
||||
await notifyUsers([ownerId], event);
|
||||
} catch (err) {
|
||||
console.error('Notification dispatch failed:', err);
|
||||
}
|
||||
@@ -424,23 +520,6 @@ function formatNow(timezone: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user