diff --git a/backend/src/__tests__/auth-phase1.5.test.js b/backend/src/__tests__/auth-phase1.5.test.js index c88f5a0..2ce5aa7 100644 --- a/backend/src/__tests__/auth-phase1.5.test.js +++ b/backend/src/__tests__/auth-phase1.5.test.js @@ -9,13 +9,13 @@ const { prisma } = require('../utils/db'); const { hashPassword, generateVerificationToken, generateVerificationCode } = require('../utils/auth'); // Mock email service -jest.mock('../utils/email', () => ({ +jest.mock('../emails', () => ({ sendVerificationEmail: jest.fn().mockResolvedValue({ success: true }), sendPasswordResetEmail: jest.fn().mockResolvedValue({ success: true }), sendWelcomeEmail: jest.fn().mockResolvedValue({ success: true }) })); -const emailService = require('../utils/email'); +const emailService = require('../emails'); // Clean up database before and after tests beforeAll(async () => { diff --git a/backend/src/__tests__/utils/email.test.js b/backend/src/__tests__/utils/email.test.js index 1f1d8e6..b156c5a 100644 --- a/backend/src/__tests__/utils/email.test.js +++ b/backend/src/__tests__/utils/email.test.js @@ -17,7 +17,7 @@ const { sendVerificationEmail, sendPasswordResetEmail, sendWelcomeEmail -} = require('../../utils/email'); +} = require('../../emails'); // Set up test environment variables beforeAll(() => { diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js index 30f424b..a32ffea 100644 --- a/backend/src/controllers/auth.js +++ b/backend/src/controllers/auth.js @@ -7,7 +7,7 @@ const { generateVerificationCode, getTokenExpiry } = require('../utils/auth'); -const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email'); +const { sendVerificationEmail, sendWelcomeEmail, sendPasswordResetEmail } = require('../emails'); const { sanitizeForEmail, timingSafeEqual } = require('../utils/sanitize'); const securityConfig = require('../config/security'); diff --git a/backend/src/controllers/user.js b/backend/src/controllers/user.js index 56e4aa4..e473b1e 100644 --- a/backend/src/controllers/user.js +++ b/backend/src/controllers/user.js @@ -1,6 +1,6 @@ const { prisma } = require('../utils/db'); const { hashPassword, comparePassword, generateToken, generateVerificationToken, generateVerificationCode } = require('../utils/auth'); -const { sendVerificationEmail } = require('../utils/email'); +const { sendVerificationEmail } = require('../emails'); const { sanitizeForEmail } = require('../utils/sanitize'); /** diff --git a/backend/src/emails/index.js b/backend/src/emails/index.js new file mode 100644 index 0000000..0b901ee --- /dev/null +++ b/backend/src/emails/index.js @@ -0,0 +1,20 @@ +/** + * Email module exports + * Provides clean API for sending emails throughout the application + */ + +const { + sendEmail, + sendVerificationEmail, + sendPasswordResetEmail, + sendWelcomeEmail, + sendRecordingSuggestionsEmail, +} = require('./service'); + +module.exports = { + sendEmail, + sendVerificationEmail, + sendPasswordResetEmail, + sendWelcomeEmail, + sendRecordingSuggestionsEmail, +}; diff --git a/backend/src/emails/service.js b/backend/src/emails/service.js new file mode 100644 index 0000000..b0e3784 --- /dev/null +++ b/backend/src/emails/service.js @@ -0,0 +1,141 @@ +/** + * Email Service using AWS SES + * Handles sending emails with template support + */ + +const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); +const { generateVerificationEmail } = require('./templates/verification'); +const { generatePasswordResetEmail } = require('./templates/password-reset'); +const { generateWelcomeEmail } = require('./templates/welcome'); +const { generateRecordingSuggestionsEmail } = require('./templates/recording-suggestions'); + +// Configure AWS SES Client +const sesClient = new SESClient({ + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}); + +/** + * Send email via AWS SES + * @param {Object} params - Email parameters + * @param {string} params.to - Recipient email address + * @param {string} params.subject - Email subject + * @param {string} params.htmlBody - HTML email body + * @param {string} params.textBody - Plain text email body (fallback) + * @returns {Promise} - SES response + */ +async function sendEmail({ to, subject, htmlBody, textBody }) { + const params = { + Source: `${process.env.SES_FROM_NAME} <${process.env.SES_FROM_EMAIL}>`, + Destination: { + ToAddresses: [to], + }, + Message: { + Subject: { + Data: subject, + Charset: 'UTF-8', + }, + Body: { + Html: { + Data: htmlBody, + Charset: 'UTF-8', + }, + Text: { + Data: textBody, + Charset: 'UTF-8', + }, + }, + }, + }; + + try { + const command = new SendEmailCommand(params); + const response = await sesClient.send(command); + console.log(`Email sent successfully to ${to}. MessageId: ${response.MessageId}`); + return { success: true, messageId: response.MessageId }; + } catch (error) { + console.error('Error sending email:', error); + throw new Error(`Failed to send email: ${error.message}`); + } +} + +/** + * Send verification email with link and PIN code + * @param {string} email - User email + * @param {string} firstName - User first name + * @param {string} verificationToken - Unique verification token + * @param {string} verificationCode - 6-digit PIN code + */ +async function sendVerificationEmail(email, firstName, verificationToken, verificationCode) { + const verificationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; + + const { subject, htmlBody, textBody } = generateVerificationEmail({ + firstName, + verificationLink, + verificationCode, + }); + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +/** + * Send password reset email with link + * @param {string} email - User email + * @param {string} firstName - User first name + * @param {string} resetToken - Unique reset token + */ +async function sendPasswordResetEmail(email, firstName, resetToken) { + const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + + const { subject, htmlBody, textBody } = generatePasswordResetEmail({ + firstName, + resetLink, + }); + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +/** + * Send welcome email after successful verification + * @param {string} email - User email + * @param {string} firstName - User first name + */ +async function sendWelcomeEmail(email, firstName) { + const { subject, htmlBody, textBody } = generateWelcomeEmail({ + firstName, + frontendUrl: process.env.FRONTEND_URL, + }); + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +/** + * Send recording suggestions notification email + * @param {string} email - User email + * @param {string} firstName - User first name + * @param {string} eventName - Name of the event + * @param {string} eventSlug - Event slug for URL + * @param {number} suggestionsCount - Number of new recording suggestions + */ +async function sendRecordingSuggestionsEmail(email, firstName, eventName, eventSlug, suggestionsCount) { + const { subject, htmlBody, textBody } = generateRecordingSuggestionsEmail({ + firstName, + eventName, + eventSlug, + suggestionsCount, + frontendUrl: process.env.FRONTEND_URL, + }); + + return sendEmail({ to: email, subject, htmlBody, textBody }); +} + +module.exports = { + sendEmail, + sendVerificationEmail, + sendPasswordResetEmail, + sendWelcomeEmail, + sendRecordingSuggestionsEmail, +}; diff --git a/backend/src/emails/templates/password-reset.js b/backend/src/emails/templates/password-reset.js new file mode 100644 index 0000000..fff16be --- /dev/null +++ b/backend/src/emails/templates/password-reset.js @@ -0,0 +1,82 @@ +/** + * Email template for password reset + * Sends secure password reset link to users + */ + +/** + * Generate password reset email content + * @param {Object} data - Template data + * @param {string} data.firstName - User's first name + * @param {string} data.resetLink - Full password reset URL with token + * @returns {Object} - Email content with subject, htmlBody, textBody + */ +function generatePasswordResetEmail(data) { + const { firstName, resetLink } = data; + + const subject = 'Reset your spotlight.cam password'; + + const htmlBody = ` + + + + + + + + +
+
+

🔐 Password Reset

+
+
+

Hi ${firstName || 'there'}! 👋

+

We received a request to reset your password for your spotlight.cam account.

+ + Reset Password + +

This link will expire in 1 hour.

+ +
+ ⚠️ Security Notice
+ If you didn't request this password reset, please ignore this email. Your password will remain unchanged. +
+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +We received a request to reset your password for your spotlight.cam account. + +Click this link to reset your password: +${resetLink} + +This link will expire in 1 hour. + +⚠️ Security Notice +If you didn't request this password reset, please ignore this email. Your password will remain unchanged. + +--- +spotlight.cam - P2P video exchange for dance events +This is an automated email. Please do not reply. + `; + + return { subject, htmlBody, textBody }; +} + +module.exports = { generatePasswordResetEmail }; diff --git a/backend/src/emails/templates/recording-suggestions.js b/backend/src/emails/templates/recording-suggestions.js new file mode 100644 index 0000000..e3ec7c7 --- /dev/null +++ b/backend/src/emails/templates/recording-suggestions.js @@ -0,0 +1,107 @@ +/** + * Email template for recording suggestions notification + * Sent when the matching system assigns recording tasks to a user + */ + +/** + * Generate recording suggestions notification email + * @param {Object} data - Template data + * @param {string} data.firstName - User's first name + * @param {string} data.eventName - Name of the event + * @param {string} data.eventSlug - Event slug for URL + * @param {number} data.suggestionsCount - Number of new recording suggestions + * @param {string} data.frontendUrl - Base frontend URL + * @returns {Object} - Email content with subject, htmlBody, textBody + */ +function generateRecordingSuggestionsEmail(data) { + const { firstName, eventName, eventSlug, suggestionsCount, frontendUrl } = data; + + const eventUrl = `${frontendUrl}/events/${eventSlug}`; + const plural = suggestionsCount === 1 ? '' : 's'; + + const subject = `New recording assignment${plural} for ${eventName}`; + + const htmlBody = ` + + + + + + + + +
+
+

🎥 New Recording Assignment${plural}

+
+
+

Hi ${firstName || 'there'}! 👋

+

Great news! The matching system has assigned you to record dance performances at ${eventName}.

+ +
+
${suggestionsCount}
+

New recording assignment${plural}

+
+ +
+ 📋 Next Steps:
+ 1. Review your assigned heats in the event dashboard
+ 2. Accept or decline each recording suggestion
+ 3. Coordinate with dancers for video exchange +
+ +

You can review and manage your recording assignments by visiting the event page:

+ + View Recording Assignments + +

+ Reminder: Recording assignments help build our dance community! + Please review them at your earliest convenience. +

+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +Great news! The matching system has assigned you to record dance performances at ${eventName}. + +📊 New Recording Assignments: ${suggestionsCount} + +📋 Next Steps: +1. Review your assigned heats in the event dashboard +2. Accept or decline each recording suggestion +3. Coordinate with dancers for video exchange + +You can review and manage your recording assignments here: +${eventUrl} + +Reminder: Recording assignments help build our dance community! +Please review them at your earliest convenience. + +--- +spotlight.cam - P2P video exchange for dance events +This is an automated email. Please do not reply. + `; + + return { subject, htmlBody, textBody }; +} + +module.exports = { generateRecordingSuggestionsEmail }; diff --git a/backend/src/emails/templates/verification.js b/backend/src/emails/templates/verification.js new file mode 100644 index 0000000..303a126 --- /dev/null +++ b/backend/src/emails/templates/verification.js @@ -0,0 +1,95 @@ +/** + * Email template for email verification + * Sends verification link and PIN code to new users + */ + +/** + * Generate verification email content + * @param {Object} data - Template data + * @param {string} data.firstName - User's first name + * @param {string} data.verificationLink - Full verification URL with token + * @param {string} data.verificationCode - 6-digit PIN code + * @returns {Object} - Email content with subject, htmlBody, textBody + */ +function generateVerificationEmail(data) { + const { firstName, verificationLink, verificationCode } = data; + + const subject = 'Verify your spotlight.cam email'; + + const htmlBody = ` + + + + + + + + +
+
+

🎥 spotlight.cam

+

Welcome to the dance community!

+
+
+

Hi ${firstName || 'there'}! 👋

+

Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address.

+ +

Option 1: Click the button

+ Verify Email Address + +
+ +

Option 2: Enter this code

+
+
${verificationCode}
+
+ +

This code will expire in 24 hours.

+ +
+ +

Didn't create an account? You can safely ignore this email.

+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address. + +Option 1: Click this link to verify +${verificationLink} + +Option 2: Enter this verification code +${verificationCode} + +This code will expire in 24 hours. + +Didn't create an account? You can safely ignore this email. + +--- +spotlight.cam - P2P video exchange for dance events +This is an automated email. Please do not reply. + `; + + return { subject, htmlBody, textBody }; +} + +module.exports = { generateVerificationEmail }; diff --git a/backend/src/emails/templates/welcome.js b/backend/src/emails/templates/welcome.js new file mode 100644 index 0000000..ab975ab --- /dev/null +++ b/backend/src/emails/templates/welcome.js @@ -0,0 +1,101 @@ +/** + * Email template for welcome message + * Sent after successful email verification + */ + +/** + * Generate welcome email content + * @param {Object} data - Template data + * @param {string} data.firstName - User's first name + * @param {string} data.frontendUrl - Base frontend URL + * @returns {Object} - Email content with subject, htmlBody, textBody + */ +function generateWelcomeEmail(data) { + const { firstName, frontendUrl } = data; + + const subject = 'Welcome to spotlight.cam! 🎉'; + + const htmlBody = ` + + + + + + + + +
+
+

🎉 Welcome to spotlight.cam!

+
+
+

Hi ${firstName || 'there'}! 👋

+

Your email has been verified! You're all set to start using spotlight.cam.

+ +

What you can do now:

+ +
+ 🎪 Join Events
+ Browse upcoming dance events and join event chat rooms to meet other dancers. +
+ +
+ 💬 Match & Chat
+ Connect with event participants for video collaborations. +
+ +
+ 🎥 Share Videos P2P
+ Exchange dance videos directly with your partners using WebRTC - no server uploads! +
+ + Explore Events + +

Happy dancing! 💃🕺

+
+ +
+ + + `; + + const textBody = ` +Hi ${firstName || 'there'}! + +Your email has been verified! You're all set to start using spotlight.cam. + +What you can do now: + +🎪 Join Events +Browse upcoming dance events and join event chat rooms to meet other dancers. + +💬 Match & Chat +Connect with event participants for video collaborations. + +🎥 Share Videos P2P +Exchange dance videos directly with your partners using WebRTC - no server uploads! + +Visit: ${frontendUrl}/events + +Happy dancing! 💃🕺 + +--- +spotlight.cam - P2P video exchange for dance events +Questions? Check out our FAQ or contact support. + `; + + return { subject, htmlBody, textBody }; +} + +module.exports = { generateWelcomeEmail }; diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index 199e568..26b1f85 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -606,6 +606,60 @@ async function saveMatchingResults(eventId, suggestions, runId = null) { // Log error but don't fail the matching operation console.error('Failed to emit recording suggestion notifications:', socketError); } + + // Send email notifications to recorders who received new suggestions + try { + const { sendRecordingSuggestionsEmail } = require('../emails'); + + // Get event details (reuse from socket notification if available) + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { slug: true, name: true }, + }); + + // Group suggestions by recorder (same logic as socket notifications) + const suggestionsByRecorder = new Map(); + for (const suggestion of newSuggestions) { + // Only notify for assigned suggestions (not NOT_FOUND) + if (suggestion.recorderId && suggestion.status === SUGGESTION_STATUS.PENDING) { + if (!suggestionsByRecorder.has(suggestion.recorderId)) { + suggestionsByRecorder.set(suggestion.recorderId, []); + } + suggestionsByRecorder.get(suggestion.recorderId).push(suggestion); + } + } + + // Get recorder details (email, firstName) + if (suggestionsByRecorder.size > 0) { + const recorderIds = Array.from(suggestionsByRecorder.keys()); + const recorders = await prisma.user.findMany({ + where: { id: { in: recorderIds } }, + select: { id: true, email: true, firstName: true, username: true }, + }); + + // Send email to each recorder + for (const recorder of recorders) { + const recorderSuggestions = suggestionsByRecorder.get(recorder.id); + if (recorderSuggestions && recorderSuggestions.length > 0) { + try { + await sendRecordingSuggestionsEmail( + recorder.email, + recorder.firstName || recorder.username, + event?.name || 'Event', + event?.slug || '', + recorderSuggestions.length + ); + } catch (emailError) { + // Log error but continue with other emails + console.error(`Failed to send recording suggestions email to ${recorder.email}:`, emailError); + } + } + } + } + } catch (emailError) { + // Log error but don't fail the matching operation + console.error('Failed to send recording suggestion email notifications:', emailError); + } } // Update event's matchingRunAt diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js deleted file mode 100644 index 1dcda00..0000000 --- a/backend/src/utils/email.js +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Email Service using AWS SES - * Handles sending emails for verification, password reset, etc. - */ - -const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses'); - -// Configure AWS SES Client -const sesClient = new SESClient({ - region: process.env.AWS_REGION || 'us-east-1', - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - }, -}); - -/** - * Send email via AWS SES - * @param {Object} params - Email parameters - * @param {string} params.to - Recipient email address - * @param {string} params.subject - Email subject - * @param {string} params.htmlBody - HTML email body - * @param {string} params.textBody - Plain text email body (fallback) - * @returns {Promise} - SES response - */ -async function sendEmail({ to, subject, htmlBody, textBody }) { - const params = { - Source: `${process.env.SES_FROM_NAME} <${process.env.SES_FROM_EMAIL}>`, - Destination: { - ToAddresses: [to], - }, - Message: { - Subject: { - Data: subject, - Charset: 'UTF-8', - }, - Body: { - Html: { - Data: htmlBody, - Charset: 'UTF-8', - }, - Text: { - Data: textBody, - Charset: 'UTF-8', - }, - }, - }, - }; - - try { - const command = new SendEmailCommand(params); - const response = await sesClient.send(command); - console.log(`Email sent successfully to ${to}. MessageId: ${response.MessageId}`); - return { success: true, messageId: response.MessageId }; - } catch (error) { - console.error('Error sending email:', error); - throw new Error(`Failed to send email: ${error.message}`); - } -} - -/** - * Send verification email with link and PIN code - * @param {string} email - User email - * @param {string} firstName - User first name - * @param {string} verificationToken - Unique verification token - * @param {string} verificationCode - 6-digit PIN code - */ -async function sendVerificationEmail(email, firstName, verificationToken, verificationCode) { - const verificationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; - - const subject = 'Verify your spotlight.cam email'; - - const htmlBody = ` - - - - - - - - -
-
-

🎥 spotlight.cam

-

Welcome to the dance community!

-
-
-

Hi ${firstName || 'there'}! 👋

-

Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address.

- -

Option 1: Click the button

- Verify Email Address - -
- -

Option 2: Enter this code

-
-
${verificationCode}
-
- -

This code will expire in 24 hours.

- -
- -

Didn't create an account? You can safely ignore this email.

-
- -
- - - `; - - const textBody = ` -Hi ${firstName || 'there'}! - -Thanks for joining spotlight.cam! To start sharing dance videos with your event partners, please verify your email address. - -Option 1: Click this link to verify -${verificationLink} - -Option 2: Enter this verification code -${verificationCode} - -This code will expire in 24 hours. - -Didn't create an account? You can safely ignore this email. - ---- -spotlight.cam - P2P video exchange for dance events -This is an automated email. Please do not reply. - `; - - return sendEmail({ to: email, subject, htmlBody, textBody }); -} - -/** - * Send password reset email with link and code - * @param {string} email - User email - * @param {string} firstName - User first name - * @param {string} resetToken - Unique reset token - */ -async function sendPasswordResetEmail(email, firstName, resetToken) { - const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; - - const subject = 'Reset your spotlight.cam password'; - - const htmlBody = ` - - - - - - - - -
-
-

🔐 Password Reset

-
-
-

Hi ${firstName || 'there'}! 👋

-

We received a request to reset your password for your spotlight.cam account.

- - Reset Password - -

This link will expire in 1 hour.

- -
- ⚠️ Security Notice
- If you didn't request this password reset, please ignore this email. Your password will remain unchanged. -
-
- -
- - - `; - - const textBody = ` -Hi ${firstName || 'there'}! - -We received a request to reset your password for your spotlight.cam account. - -Click this link to reset your password: -${resetLink} - -This link will expire in 1 hour. - -⚠️ Security Notice -If you didn't request this password reset, please ignore this email. Your password will remain unchanged. - ---- -spotlight.cam - P2P video exchange for dance events -This is an automated email. Please do not reply. - `; - - return sendEmail({ to: email, subject, htmlBody, textBody }); -} - -/** - * Send welcome email after successful verification - * @param {string} email - User email - * @param {string} firstName - User first name - */ -async function sendWelcomeEmail(email, firstName) { - const subject = 'Welcome to spotlight.cam! 🎉'; - - const htmlBody = ` - - - - - - - - -
-
-

🎉 Welcome to spotlight.cam!

-
-
-

Hi ${firstName || 'there'}! 👋

-

Your email has been verified! You're all set to start using spotlight.cam.

- -

What you can do now:

- -
- 🎪 Join Events
- Browse upcoming dance events and join event chat rooms to meet other dancers. -
- -
- 💬 Match & Chat
- Connect with event participants for video collaborations. -
- -
- 🎥 Share Videos P2P
- Exchange dance videos directly with your partners using WebRTC - no server uploads! -
- - Explore Events - -

Happy dancing! 💃🕺

-
- -
- - - `; - - const textBody = ` -Hi ${firstName || 'there'}! - -Your email has been verified! You're all set to start using spotlight.cam. - -What you can do now: - -🎪 Join Events -Browse upcoming dance events and join event chat rooms to meet other dancers. - -💬 Match & Chat -Connect with event participants for video collaborations. - -🎥 Share Videos P2P -Exchange dance videos directly with your partners using WebRTC - no server uploads! - -Visit: ${process.env.FRONTEND_URL}/events - -Happy dancing! 💃🕺 - ---- -spotlight.cam - P2P video exchange for dance events -Questions? Check out our FAQ or contact support. - `; - - return sendEmail({ to: email, subject, htmlBody, textBody }); -} - -module.exports = { - sendEmail, - sendVerificationEmail, - sendPasswordResetEmail, - sendWelcomeEmail, -};