diff --git a/backend/server.js b/backend/server.js index 56c00ff..6045767 100644 --- a/backend/server.js +++ b/backend/server.js @@ -768,6 +768,28 @@ app.post('/api/editor/settings/test', requireAuth, editorOnly, directorOnly, wra res.json(result); })); +// Send a test Discord DM to the current user +app.post('/api/notify/test', requireAuth, wrap(async (req, res) => { + const user = req.user; + if (!user.discord) return res.status(400).json({ error: 'No Discord username set. Add it in Discord settings above.' }); + + const result = await notify.sendDiscordDM({ + discordUsername: user.discord, + content: '🎉 This is a test message from Synthica!\n\nYour Discord notifications are working correctly.', + embed: { + title: '✅ Discord Connected!', + description: 'You will now receive project notifications via DM.', + color: 2589, + footer: { text: 'Synthica Notifications' }, + }, + }); + + if (!result.ok && !result.skipped) { + return res.status(400).json({ error: result.error || 'Failed to send DM' }); + } + res.json({ ok: true }); +})); + // --- Track 4: Researcher dashboard ----------------------------------------- const researcherOnly = (req, res, next) => { if (req.user.kind !== 'researcher' && !req.user.allViewsDemo) { diff --git a/backend/src/email.js b/backend/src/email.js index aba6f3f..38358b3 100644 --- a/backend/src/email.js +++ b/backend/src/email.js @@ -122,3 +122,165 @@ export function emailDecision({ authorEmail, authorName, title, decision }) { signoff: 'Thank you for contributing,', }); } + +// ============================================================ +// Notification email templates for platform events +// ============================================================ + +// New reply/comment on post +export function emailNewComment({ toEmail, toName, authorName, postTitle, postLink }) { + const first = String(toName || 'there').split(/\s+/)[0]; + return actionEmail({ + to: toEmail, + subject: `${authorName} commented on your post`, + heading: 'New comment on your post', + intro: `Hi ${esc(first)},`, + blocks: [ + `${esc(authorName)} commented on your post${esc(postTitle)}`, + ], + button: { label: 'View comment', url: `${SITE}${postLink}` }, + signoff: 'Stay engaged,', + }); +} + +// Project invitation received +export function emailProjectInvite({ toEmail, toName, inviterName, projectTitle, projectLink }) { + const first = String(toName || 'there').split(/\s+/)[0]; + return actionEmail({ + to: toEmail, + subject: `You've been invited to join: ${projectTitle}`, + heading: 'Project invitation', + intro: `Hi ${esc(first)},`, + blocks: [ + `${esc(inviterName)} invited you to join the project ${esc(projectTitle)}`, + ], + button: { label: 'View project', url: `${SITE}${projectLink}` }, + signoff: 'Happy collaborating,', + }); +} + +// Application status update +export function emailApplicationUpdate({ toEmail, toName, programName, status, statusMessage, link }) { + const first = String(toName || 'there').split(/\s+/)[0]; + const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'updated'; + return actionEmail({ + to: toEmail, + subject: `Your application to ${programName} was ${statusLabel}`, + heading: `Application ${statusLabel}`, + intro: `Hi ${esc(first)},`, + blocks: [ + statusMessage || `Your application to ${esc(programName)} has been ${statusLabel}.`, + ], + button: link ? { label: 'View application', url: `${SITE}${link}` } : undefined, + signoff: 'Best regards,', + }); +} + +// Mentor request received +export function emailMentorRequest({ toEmail, toName, menteeName, menteeMessage, requestLink }) { + const first = String(toName || 'there').split(/\s+/)[0]; + return actionEmail({ + to: toEmail, + subject: `New mentorship request from ${menteeName}`, + heading: 'New mentorship request', + intro: `Hi ${esc(first)},`, + blocks: [ + `${esc(menteeName)} requested your mentorship.`, + menteeMessage ? `Message: "${esc(menteeMessage)}"` : '', + ], + button: { label: 'View request', url: `${SITE}${requestLink}` }, + signoff: 'Happy mentoring,', + }); +} + +// New community announcement +export function emailAnnouncement({ toEmail, toName, authorName, title, content, link }) { + const first = String(toName || 'there').split(/\s+/)[0]; + return actionEmail({ + to: toEmail, + subject: `Announcement: ${title}`, + heading: esc(title), + intro: `Hi ${esc(first)},`, + blocks: [ + `From ${esc(authorName)}:`, + content || '', + ], + button: link ? { label: 'Read more', url: `${SITE}${link}` } : undefined, + signoff: 'Stay connected,', + }); +} + +// Role/privilege granted +export function emailRoleGranted({ toEmail, toName, roleName, roleDescription, dashboardLink }) { + const first = String(toName || 'there').split(/\s+/)[0]; + return actionEmail({ + to: toEmail, + subject: `You've been granted: ${roleName}`, + heading: 'New role assigned', + intro: `Hi ${esc(first)},`, + blocks: [ + `Congratulations! You've been assigned the ${esc(roleName)} role.`, + roleDescription || '', + ], + button: { label: 'Go to dashboard', url: `${SITE}${dashboardLink}` }, + signoff: 'Congratulations,', + }); +} + +// Independent researcher proposal update +export function emailProposalUpdate({ toEmail, toName, proposalTitle, status, feedback, link }) { + const first = String(toName || 'there').split(/\s+/)[0]; + const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'under review'; + return actionEmail({ + to: toEmail, + subject: `Your proposal "${proposalTitle}" was ${statusLabel}`, + heading: `Proposal ${statusLabel}`, + intro: `Hi ${esc(first)},`, + blocks: [ + `Your proposal ${esc(proposalTitle)} has been ${statusLabel}.`, + feedback ? `Feedback: ${esc(feedback)}` : '', + ], + button: link ? { label: 'View proposal', url: `${SITE}${link}` } : undefined, + signoff: 'Best of luck,', + }); +} + +// Competition/listing update +export function emailListingUpdate({ toEmail, toName, listingTitle, status, listingLink }) { + const first = String(toName || 'there').split(/\s+/)[0]; + return actionEmail({ + to: toEmail, + subject: `Update on your ${listingTitle} application`, + heading: 'Application update', + intro: `Hi ${esc(first)},`, + blocks: [ + `There's an update regarding your application to ${esc(listingTitle)}.`, + ], + button: { label: 'View details', url: `${SITE}${listingLink}` }, + signoff: 'Stay tuned,', + }); +} + +// General notification helper - sends any notification type as email +export function sendNotificationEmail({ toEmail, toName, type, data }) { + if (!toEmail) return { ok: false, reason: 'no email' }; + + const templates = { + comment: () => emailNewComment({ toEmail, toName, ...data }), + project_invite: () => emailProjectInvite({ toEmail, toName, ...data }), + application_update: () => emailApplicationUpdate({ toEmail, toName, ...data }), + mentor_request: () => emailMentorRequest({ toEmail, toName, ...data }), + announcement: () => emailAnnouncement({ toEmail, toName, ...data }), + role_granted: () => emailRoleGranted({ toEmail, toName, ...data }), + proposal_update: () => emailProposalUpdate({ toEmail, toName, ...data }), + listing_update: () => emailListingUpdate({ toEmail, toName, ...data }), + }; + + const template = templates[type]; + if (!template) { + console.warn(`[email] unknown notification type: ${type}`); + return { ok: false, reason: 'unknown type' }; + } + + return template(); +} diff --git a/backend/src/notify.js b/backend/src/notify.js index 64a45a3..66b62c6 100644 --- a/backend/src/notify.js +++ b/backend/src/notify.js @@ -17,6 +17,10 @@ // or via the DISCORD_WEBHOOK_URL env var (which also makes it survive restarts). let webhookUrl = (process.env.DISCORD_WEBHOOK_URL || '').trim(); +// Discord bot token for sending DMs to users +const DISCORD_BOT_TOKEN = (process.env.DISCORD_BOT_TOKEN || '').trim(); +// Discord guild invite link for users to join +export const DISCORD_SERVER_LINK = 'https://discord.com/invite/8wPzZkGy5Z'; // A second, generic channel — point it at a WhatsApp relay (Twilio/Make/Zapier // webhook) that forwards a JSON {text} payload to a WhatsApp number/group. let whatsappUrl = (process.env.WHATSAPP_WEBHOOK_URL || '').trim(); @@ -35,16 +39,17 @@ export const setWhatsapp = (url) => { whatsappUrl = (url || '').trim(); return whatsappUrl; }; +export const hasDiscordBot = () => !!DISCORD_BOT_TOKEN; // POST JSON with a hard timeout. Resolves to a small result object and never // throws, so fire-and-forget callers can't trigger unhandled rejections. -async function postJson(url, payload) { +async function postJson(url, payload, headers = { 'Content-Type': 'application/json' }) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS); try { const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify(payload), signal: controller.signal, }); @@ -68,6 +73,155 @@ export async function postDiscord(payload) { return postJson(webhookUrl, payload); } +// Get Discord user ID from username (requires the bot to share a server with the user) +async function getDiscordUserId(username) { + if (!DISCORD_BOT_TOKEN) return null; + try { + // Search for user by username (Discord API limitation: this only works if bot shares a server) + // Alternative: require users to provide their Discord User ID directly + const res = await fetch(`https://discord.com/api/v10/users/@me`, { + headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}` }, + }); + // For now, we return the username as-is - actual DM requires User ID + // Users need to provide their Discord User ID for DMs to work reliably + return username; // Return as-is; the DM function will handle the actual sending + } catch (e) { + console.warn('[discord] failed to get user ID:', e.message); + return null; + } +} + +// Send a DM to a Discord user via bot (requires user to be in a shared server) +export async function sendDiscordDM({ discordUsername, content, embed }) { + if (!DISCORD_BOT_TOKEN) { + console.log('[discord] (no DISCORD_BOT_TOKEN) would DM', discordUsername); + return { ok: false, skipped: true, error: 'no bot token' }; + } + try { + let userId = discordUsername; + + // If it's a username (not just numbers), try to find the user ID + if (!/^\d+$/.test(discordUsername)) { + console.log(`[discord] Looking up user "${discordUsername}" in guild...`); + + let apiFailed = false; + try { + const membersRes = await fetch( + `https://discord.com/api/v10/guilds/1512337763536601169/members?limit=1000`, + { headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}` } } + ); + + if (!membersRes.ok) { + const errText = await membersRes.text(); + console.error('[discord] guild API error:', errText); + // Check if it's an auth issue + if (membersRes.status === 401 || membersRes.status === 403) { + return { ok: false, error: 'Cannot access Discord guild. Bot token may be invalid or expired.' }; + } + apiFailed = true; + } else { + const members = await membersRes.json(); + const match = members.find(m => + m.user?.username?.toLowerCase() === discordUsername.toLowerCase() || + m.nick?.toLowerCase() === discordUsername.toLowerCase() + ); + + if (match?.user?.id) { + userId = match.user.id; + console.log(`[discord] Found user "${discordUsername}" as ${userId}`); + } + } + } catch (e) { + console.warn(`[discord] Could not search guild members:`, e.message); + apiFailed = true; + } + + // If API failed or user not found, return appropriate error + if (apiFailed || !/^\d+$/.test(userId)) { + return { + ok: false, + error: `User "${discordUsername}" not found. Make sure they are in the Synthica Discord server.` + }; + } + + // If we still don't have a user ID, the user might not be in the server + if (!/^\d+$/.test(userId)) { + return { + ok: false, + error: `User "${discordUsername}" not found. Make sure they are in the Synthica Discord server.` + }; + } + } + + // Create a DM channel with the user + const dmResponse = await fetch('https://discord.com/api/v10/users/@me/channels', { + method: 'POST', + headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipient_id: userId }), + }); + + if (!dmResponse.ok) { + const err = await dmResponse.text(); + console.error('[discord] failed to create DM channel:', err); + if (err.includes('403') || err.includes('Cannot send messages to this user')) { + return { ok: false, error: 'User has DMs disabled. They need to allow DMs from server members in Discord settings.' }; + } + return { ok: false, error: `Failed to create DM: ${err}` }; + } + + const dmChannel = await dmResponse.json(); + + // Send the message + const payload = { channel_id: dmChannel.id }; + if (content) payload.content = content; + if (embed) payload.embeds = [embed]; + + const msgRes = await fetch(`https://discord.com/api/v10/channels/${dmChannel.id}/messages`, { + method: 'POST', + headers: { 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!msgRes.ok) { + const err = await msgRes.text(); + console.error('[discord] failed to send message:', err); + return { ok: false, error: err }; + } + + return { ok: true }; + } catch (e) { + console.error('[discord] DM error:', e); + return { ok: false, error: e.message }; + } +} + +function formatDiscordNotification({ type, title, body, link }) { + const fields = []; + if (title) fields.push({ name: 'Notification', value: title, inline: false }); + if (body) fields.push({ name: 'Details', value: body, inline: false }); + if (link) fields.push({ name: 'Action', value: `[View on Synthica](${link})`, inline: false }); + + return { + username: 'Synthica', + embeds: [{ + title: `🔔 ${title || 'New notification'}`, + description: body || '', + color: 0x2589ed, // Synthica brand blue + fields, + footer: { text: 'Synthica Notifications' }, + timestamp: new Date().toISOString(), + }], + }; +} + +// Send notification via Discord DM (fire-and-forget) +export function notifyDiscord({ discordUsername, type, title, body, link }) { + if (!DISCORD_BOT_TOKEN && !discordUsername) return; + const payload = formatDiscordNotification({ type, title, body, link }); + // Fire and forget + sendDiscordDM({ discordUsername, content: null, embed: payload.embeds[0] }).catch(() => {}); +} + // Called from the workflow whenever a paper advances or is declined. export function notifyMove({ title, paperId, category, label, decision }) { if (!webhookUrl && !whatsappUrl) return; diff --git a/backend/src/store.js b/backend/src/store.js index ae6f311..9a16515 100644 --- a/backend/src/store.js +++ b/backend/src/store.js @@ -10,8 +10,8 @@ import { STAGE, STAGE_LABEL, EDITOR_ROLES, ASSOCIATE_TOTAL_ROUNDS, TASK_STATUS, import * as seed from './seed.js'; import { verifyPassword, hashPassword } from './passwords.js'; import { safeUrl } from './url.js'; -import { notifyMove, notifyEvent } from './notify.js'; -import { emailDecision, sendEmail, actionEmail } from './email.js'; +import { notifyMove, notifyEvent, notifyDiscord } from './notify.js'; +import { emailDecision, sendEmail, actionEmail, sendNotificationEmail } from './email.js'; import { registerDoi } from './doi.js'; import { generateSecret as totpGenerateSecret, otpauthUrl as totpOtpauthUrl, verifyTotp } from './totp.js'; @@ -53,7 +53,7 @@ function buildSeed() { }; } -// Push an in-app notification to a user (fire-and-forget within mutations). +// Push an in-app notification to a user and trigger email/Discord via notifyUser. function pushNotif(userId, { type, title, body, link }) { if (!userId || !db.notifications) return; db.notifications.push({ @@ -67,6 +67,158 @@ function pushNotif(userId, { type, title, body, link }) { at: new Date().toISOString(), }); emit(userId, 'notification', { title, body, link }); + + // Also trigger email/Discord notifications via notifyUser + // Use setImmediate to not block the mutation + setImmediate(() => { + try { + notifyUser(userId, { type, title, body, link }, true); + } catch (e) { + console.warn('[notify] pushNotif -> notifyUser failed:', e.message); + } + }); +} + +// Check if a notification type is enabled for a user +function isNotificationEnabled(user, channel, type) { + const prefs = user?.notifications; + if (channel === 'email') { + if (!prefs) return true; // Default: email enabled + return prefs.email?.[type] ?? true; + } + if (channel === 'discord') { + if (!prefs) return !!user?.discord; // Default: enabled if user has discord username + return prefs.discord?.[type] ?? !!user?.discord; // Default: enabled if user has discord username + } + return false; +} + +// Get the link URL for a notification +function getNotificationLink(notifData) { + const SITE = process.env.FRONTEND_URL || 'https://app.synthica.org'; + const base = SITE.replace(/\/$/, ''); + return notifData.link ? `${base}${notifData.link}` : base; +} + +// Comprehensive notification dispatcher - sends to all enabled channels +// Types: comment, project_invite, application_update, mentor_request, +// announcement, role_granted, proposal_update, listing_update +export function notifyUser(userId, { type, title, body, link }, skipPush = false) { + const user = getUserById(userId); + if (!user) return; + + const linkUrl = getNotificationLink({ link }); + + // 1. In-app notification (always sent) - unless we are already being called from pushNotif + if (!skipPush) { + pushNotif(userId, { type, title, body, link }); + } + + // 2. Email notification (if enabled for this type) + if (isNotificationEnabled(user, 'email', type)) { + try { + sendNotificationEmail({ + toEmail: user.email, + toName: user.name, + type, + data: { title, body, link: linkUrl, authorName: '', postTitle: '', postLink: link }, + }); + } catch (e) { + console.warn(`[notify] email failed for user ${userId}:`, e.message); + } + } + + // 3. Discord DM (if enabled for this type and user has discord username) + if (user.discord && isNotificationEnabled(user, 'discord', type)) { + try { + notifyDiscord({ + discordUsername: user.discord, + type, + title, + body, + link: linkUrl, + }); + } catch (e) { + console.warn(`[notify] discord failed for user ${userId}:`, e.message); + } + } +} + +// Helper functions for specific notification types +export function notifyComment({ targetUserId, authorName, postTitle, postLink }) { + notifyUser(targetUserId, { + type: 'comment', + title: `${authorName} commented on your post`, + body: `"${postTitle}"`, + link: postLink, + }); +} + +export function notifyProjectInvite({ targetUserId, inviterName, projectTitle, projectLink }) { + notifyUser(targetUserId, { + type: 'project_invite', + title: `You've been invited to join: ${projectTitle}`, + body: `${inviterName} invited you`, + link: projectLink, + }); +} + +export function notifyApplicationUpdate({ targetUserId, programName, status, statusMessage, link }) { + const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'updated'; + notifyUser(targetUserId, { + type: 'application_update', + title: `Your application to ${programName} was ${statusLabel}`, + body: statusMessage || `Your application has been ${statusLabel}`, + link, + }); +} + +export function notifyMentorRequest({ targetUserId, menteeName, menteeMessage, requestLink }) { + notifyUser(targetUserId, { + type: 'mentor_request', + title: `New mentorship request from ${menteeName}`, + body: menteeMessage || 'Someone requested your mentorship', + link: requestLink, + }); +} + +export function notifyAnnouncement({ targetUserIds, authorName, title, content, link }) { + for (const userId of targetUserIds) { + notifyUser(userId, { + type: 'announcement', + title: `Announcement: ${title}`, + body: `From ${authorName}`, + link, + }); + } +} + +export function notifyRoleGranted({ targetUserId, roleName, roleDescription, dashboardLink }) { + notifyUser(targetUserId, { + type: 'role_granted', + title: `You've been granted: ${roleName}`, + body: roleDescription || `You now have the ${roleName} role`, + link: dashboardLink, + }); +} + +export function notifyProposalUpdate({ targetUserId, proposalTitle, status, feedback, link }) { + const statusLabel = status === 'approved' ? 'approved' : status === 'rejected' ? 'not approved' : 'under review'; + notifyUser(targetUserId, { + type: 'proposal_update', + title: `Your proposal "${proposalTitle}" was ${statusLabel}`, + body: feedback || `Your proposal is now ${statusLabel}`, + link, + }); +} + +export function notifyListingUpdate({ targetUserId, listingTitle, status, link }) { + notifyUser(targetUserId, { + type: 'listing_update', + title: `Update on your ${listingTitle} application`, + body: `There's an update regarding your application`, + link, + }); } // Notify every editor who reviewed a paper (per policy: reviewers are told when @@ -675,6 +827,42 @@ export function updateProfile(userId, patch) { if (typeof patch.avatarUrl === 'string') u.avatarUrl = safeUrl(patch.avatarUrl, 400); if (typeof patch.resumeUrl === 'string') u.resumeUrl = safeUrl(patch.resumeUrl, 400); if (typeof patch.discord === 'string') u.discord = patch.discord.trim().slice(0, 60); + + // Notification preferences + if (patch.notifications !== undefined) { + // Initialize notifications object if it doesn't exist + if (!u.notifications) u.notifications = {}; + // Email notifications (by type) + if (typeof patch.notifications.email === 'object') { + u.notifications.email = { + ...u.notifications.email, + comment: patch.notifications.email.comment ?? true, + project_invite: patch.notifications.email.project_invite ?? true, + application_update: patch.notifications.email.application_update ?? true, + mentor_request: patch.notifications.email.mentor_request ?? true, + announcement: patch.notifications.email.announcement ?? true, + role_granted: patch.notifications.email.role_granted ?? true, + proposal_update: patch.notifications.email.proposal_update ?? true, + listing_update: patch.notifications.email.listing_update ?? true, + digest: patch.notifications.email.digest ?? false, + }; + } + // Discord notifications (by type) + if (typeof patch.notifications.discord === 'object') { + u.notifications.discord = { + ...u.notifications.discord, + comment: patch.notifications.discord.comment ?? true, + project: patch.notifications.discord.project ?? true, + project_invite: patch.notifications.discord.project_invite ?? true, + application_update: patch.notifications.discord.application_update ?? true, + mentor_request: patch.notifications.discord.mentor_request ?? true, + announcement: patch.notifications.discord.announcement ?? true, + role_granted: patch.notifications.discord.role_granted ?? true, + proposal_update: patch.notifications.discord.proposal_update ?? true, + listing_update: patch.notifications.discord.listing_update ?? true, + }; + } + } if (typeof patch.linkedinUrl === 'string') u.linkedinUrl = safeUrl(patch.linkedinUrl); if (typeof patch.websiteUrl === 'string') u.websiteUrl = safeUrl(patch.websiteUrl); if (typeof patch.githubUrl === 'string') u.githubUrl = safeUrl(patch.githubUrl); @@ -2806,11 +2994,12 @@ export function inviteToProject({ projectId, leadId, email }) { if (existing) { if (p.members.includes(existing.id)) throw httpError(409, 'They are already on this project'); p.members.push(existing.id); - pushNotif(existing.id, { type: 'project', title: `You were added to ${p.title}`, body: `Invited by ${lead?.name || 'the project lead'}`, link: `/researcher/project/${p.id}` }); - sendEmail({ - to: addr, - subject: `You've been added to "${p.title}" on Synthica`, - text: `Hi ${existing.name},\n\n${lead?.name || 'A project lead'} added you to the project "${p.title}".\nSign in to see it: ${process.env.FRONTEND_URL || 'https://app.synthica.org'}\n\n— The Synthica Team`, + // Use notifyUser for full notification support (in-app + email + Discord DM) + notifyProjectInvite({ + targetUserId: existing.id, + inviterName: lead?.name || 'the project lead', + projectTitle: p.title, + projectLink: `/researcher/project/${p.id}`, }); schedulePersist(); return { status: 'added', name: existing.name }; diff --git a/dashboards/src/api.js b/dashboards/src/api.js index 5e1f8dd..c955f54 100644 --- a/dashboards/src/api.js +++ b/dashboards/src/api.js @@ -319,6 +319,8 @@ export const api = { getSettings: () => request('/editor/settings'), setWebhook: (discordWebhookUrl) => request('/editor/settings', { method: 'PUT', body: { discordWebhookUrl } }), testWebhook: () => request('/editor/settings/test', { method: 'POST' }), + // Discord DM test + notifyTest: () => request('/notify/test', { method: 'POST' }), // Expertise mentors (ROLE_WORKFLOWS §7) // Directory + booking (any researcher): diff --git a/dashboards/src/components/Bell.jsx b/dashboards/src/components/Bell.jsx index 6ff9f8c..b3c3cea 100644 --- a/dashboards/src/components/Bell.jsx +++ b/dashboards/src/components/Bell.jsx @@ -73,12 +73,30 @@ export default function Bell() { if (n.link) navigate(n.link); }; + const goToSettings = (e) => { + e.stopPropagation(); + e.preventDefault(); + setOpen(false); + // Extract base path from current location (e.g., /editor, /moderator, /researcher) + const pathname = window.location.pathname; + const match = pathname.match(/^(\/editor|\/moderator|\/researcher)/); + const basePath = match ? match[1] : ''; + navigate(`${basePath}/account?tab=notifications`); + }; + const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'; return (
- {open && ( @@ -88,6 +106,14 @@ export default function Bell() { {rtStatus !== 'connected' && ( • reconnecting… )} +
{items.length === 0 ? (
diff --git a/dashboards/src/components/Toggle.jsx b/dashboards/src/components/Toggle.jsx new file mode 100644 index 0000000..0afe082 --- /dev/null +++ b/dashboards/src/components/Toggle.jsx @@ -0,0 +1,39 @@ +// Toggle switch component +export default function Toggle({ checked, onChange, disabled = false }) { + return ( + + ); +} diff --git a/dashboards/src/pages/Account.jsx b/dashboards/src/pages/Account.jsx index 282575c..9e06bd7 100644 --- a/dashboards/src/pages/Account.jsx +++ b/dashboards/src/pages/Account.jsx @@ -6,6 +6,7 @@ import { Security } from './Tools.jsx'; import Certificates from '../components/Certificates.jsx'; import Referrals from '../components/Referrals.jsx'; import PrivacySafety from '../components/PrivacySafety.jsx'; +import Notifications from './Notifications.jsx'; import { useAuth } from '../auth.jsx'; // The account center — one place to manage everything tied to "you": your public @@ -21,6 +22,7 @@ export default function Account() { // Tabs are gated by account kind: only researchers earn role certificates. const tabs = [ ['profile', 'Profile', () => ], + ['notifications', 'Notifications', () => ], ['tools', 'Résumé & tools', () => ], isResearcher && ['certs', 'Certificates', () => ], ['referrals', 'Refer & invite', () => ], diff --git a/dashboards/src/pages/Notifications.jsx b/dashboards/src/pages/Notifications.jsx new file mode 100644 index 0000000..1abe77f --- /dev/null +++ b/dashboards/src/pages/Notifications.jsx @@ -0,0 +1,307 @@ +import { useState, Component } from 'react'; +import { Card, Button, Badge } from '../components/ui.jsx'; +import Toggle from '../components/Toggle.jsx'; +import { api } from '../api.js'; +import { useAuth } from '../auth.jsx'; +import { useToast } from '../components/toast.jsx'; + +// Error boundary to catch rendering errors +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + componentDidCatch(error, info) { + console.error('Notifications Error:', error, info); + } + render() { + if (this.state.hasError) { + return ( + +

Error loading notifications

+

{this.state.error?.message}

+ +
+ ); + } + return this.props.children; + } +} + +const DISCORD_SERVER_LINK = 'https://discord.com/invite/8wPzZkGy5Z'; + +const NOTIFICATION_TYPES = [ + { key: 'comment', label: 'Comments on your posts', desc: 'When someone comments on your posts' }, + { key: 'project', label: 'Project updates', desc: 'When you are added to or removed from projects' }, + { key: 'project_invite', label: 'Project invitations', desc: 'When you receive a project invitation' }, + { key: 'application_update', label: 'Application updates', desc: 'Status updates on your applications' }, + { key: 'mentor_request', label: 'Mentor requests', desc: 'When someone requests your mentorship' }, + { key: 'announcement', label: 'Announcements', desc: 'Community announcements and updates' }, + { key: 'role_granted', label: 'Role assignments', desc: 'When you are assigned a new role' }, + { key: 'proposal_update', label: 'Proposal updates', desc: 'Status updates on your proposals' }, + { key: 'listing_update', label: 'Listing updates', desc: 'Updates on listings you have applied to' }, +]; + +function NotificationsContent() { + const { user, refreshUser } = useAuth(); + const toast = useToast(); + + // Guard: show loading if no user + if (!user) { + return ( + +

+ Loading notification settings... +

+
+ ); + } + + const [discord, setDiscord] = useState(user?.discord || ''); + const [savingDiscord, setSavingDiscord] = useState(false); + const [savingPrefs, setSavingPrefs] = useState(false); + + // Initialize notification preferences from user data + const emailPrefs = user?.notifications?.email || {}; + const discordPrefs = user?.notifications?.discord || {}; + + const [emailEnabled, setEmailEnabled] = useState(() => + NOTIFICATION_TYPES.reduce((acc, t) => { + acc[t.key] = emailPrefs[t.key] !== undefined ? emailPrefs[t.key] : true; + return acc; + }, {}) + ); + + const [discordEnabled, setDiscordEnabled] = useState(() => + NOTIFICATION_TYPES.reduce((acc, t) => { + acc[t.key] = discordPrefs[t.key] ?? true; // Default to true + return acc; + }, {}) + ); + + const handleDiscordSave = async () => { + setSavingDiscord(true); + try { + await api.updateProfile({ discord: discord.trim() }); + await refreshUser(); + toast.success('Discord username saved'); + } catch (e) { + toast.error(e.message); + } finally { + setSavingDiscord(false); + } + }; + + const [sendingTest, setSendingTest] = useState(false); + const handleSendTestDM = async () => { + if (!user?.discord) return toast.error('No Discord username set'); + setSendingTest(true); + try { + const data = await api.notifyTest(); + toast.success('Test DM sent! Check your Discord.'); + } catch (e) { + toast.error(e.message); + } finally { + setSendingTest(false); + } + }; + + const handleToggleEmail = (key) => { + setEmailEnabled(prev => ({ ...prev, [key]: !prev[key] })); + }; + + const handleToggleDiscord = (key) => { + setDiscordEnabled(prev => ({ ...prev, [key]: !prev[key] })); + }; + + const handleSavePreferences = async () => { + setSavingPrefs(true); + try { + const notifications = { + email: emailEnabled, + discord: discordEnabled, + }; + await api.updateProfile({ notifications }); + await refreshUser(); + toast.success('Notification preferences saved'); + } catch (e) { + toast.error(e.message); + } finally { + setSavingPrefs(false); + } + }; + + // Check if Discord is connected + const hasDiscord = user?.discord && user.discord.trim().length > 0; + + return ( +
+ {/* Discord Settings */} + +

+ + + + Discord Notifications + {hasDiscord ? 'Connected' : 'Not connected'} +

+ + {!hasDiscord ? ( +
+
+

+ To receive Discord DM notifications, you need to: +

+
    +
  1. Join the Synthica Discord server
  2. +
  3. Enter your Discord username or User ID below
  4. +
+
+ +
+ setDiscord(e.target.value)} + className="form-input" + style={{ flex: 1 }} + /> + +
+ + + + + + Join Synthica Discord Server + +
+ ) : ( +
+
+ Connected + + Receiving DMs as: {user.discord} + +
+
+ setDiscord(e.target.value)} + className="form-input" + style={{ flex: 1 }} + /> + + +
+
+ )} +
+ + {/* Email Notifications */} + +

+ + + + + Email Notifications + Enabled by default +

+ +
+ {NOTIFICATION_TYPES.map(({ key, label, desc }) => ( +
+
+
{label}
+
{desc}
+
+ handleToggleEmail(key)} + /> +
+ ))} +
+
+ + {/* Discord Notifications */} + {hasDiscord && ( + +

+ + + + Discord DM Notifications + Connected +

+ +
+ {NOTIFICATION_TYPES.map(({ key, label, desc }) => ( +
+
+
{label}
+
{desc}
+
+ handleToggleDiscord(key)} + /> +
+ ))} +
+
+ )} + + +
+ ); +} + +// Wrap with error boundary for production error catching +export default function Notifications() { + return ( + + + + ); +} diff --git a/dashboards/src/pages/PublicProfile.jsx b/dashboards/src/pages/PublicProfile.jsx index 32508fe..44b49c1 100644 --- a/dashboards/src/pages/PublicProfile.jsx +++ b/dashboards/src/pages/PublicProfile.jsx @@ -49,7 +49,6 @@ export default function PublicProfile() {