diff --git a/src/components/dashboard/OfficerManagement/OfficerManagement.tsx b/src/components/dashboard/OfficerManagement/OfficerManagement.tsx index 689a41a..bfd097e 100644 --- a/src/components/dashboard/OfficerManagement/OfficerManagement.tsx +++ b/src/components/dashboard/OfficerManagement/OfficerManagement.tsx @@ -5,6 +5,7 @@ import type { User, Officer } from '../../../schemas/pocketbase/schema'; import { Button } from '../universal/Button'; import toast from 'react-hot-toast'; import { Toast } from '../universal/Toast'; +import { EmailClient } from '../../../scripts/email/EmailClient'; // Interface for officer with expanded user data interface OfficerWithUser extends Officer { @@ -252,12 +253,34 @@ export default function OfficerManagement() { try { const pb = auth.getPocketBase(); + // Store previous values for the email notification + const previousRole = officerToReplace.existingOfficer.role; + const previousType = officerToReplace.existingOfficer.type; + const currentUserId = auth.getUserId(); + // Update the existing officer record await pb.collection(Collections.OFFICERS).update(officerToReplace.existingOfficer.id, { role: officerToReplace.newRole, type: officerToReplace.newType }); + // Send email notification (non-blocking) + try { + await EmailClient.notifyOfficerRoleChange( + officerToReplace.existingOfficer.id, + previousRole, + previousType, + officerToReplace.newRole, + officerToReplace.newType, + currentUserId || undefined, + false // This is an update, not a new officer + ); + console.log('Officer role change notification email sent successfully'); + } catch (emailError) { + console.error('Failed to send officer role change notification email:', emailError); + // Don't show error to user - email failure shouldn't disrupt the main operation + } + // Show success message toast.success(`Officer role updated successfully for ${officerToReplace.existingOfficer.expand?.user.name}`); @@ -427,8 +450,13 @@ export default function OfficerManagement() { // Check if user is already an officer const existingOfficer = officers.find(officer => officer.expand?.user.id === user.id); + const currentUserId = auth.getUserId(); if (existingOfficer) { + // Store previous values for the email notification + const previousRole = existingOfficer.role; + const previousType = existingOfficer.type; + // Update existing officer const updatedOfficer = await pb.collection(Collections.OFFICERS).update(existingOfficer.id, { role: newOfficerRole, @@ -436,6 +464,23 @@ export default function OfficerManagement() { }); successfulUpdates.push(updatedOfficer); + + // Send email notification for role update (non-blocking) + try { + await EmailClient.notifyOfficerRoleChange( + existingOfficer.id, + previousRole, + previousType, + newOfficerRole, + validType, + currentUserId || undefined, + false // This is an update, not a new officer + ); + console.log(`Officer role change notification sent for ${user.name}`); + } catch (emailError) { + console.error(`Failed to send officer role change notification for ${user.name}:`, emailError); + // Don't show error to user - email failure shouldn't disrupt the main operation + } } else { // Create new officer record const createdOfficer = await pb.collection(Collections.OFFICERS).create({ @@ -445,6 +490,23 @@ export default function OfficerManagement() { }); successfulCreations.push(createdOfficer); + + // Send email notification for new officer (non-blocking) + try { + await EmailClient.notifyOfficerRoleChange( + createdOfficer.id, + undefined, // No previous role for new officers + undefined, // No previous type for new officers + newOfficerRole, + validType, + currentUserId || undefined, + true // This is a new officer + ); + console.log(`New officer notification sent for ${user.name}`); + } catch (emailError) { + console.error(`Failed to send new officer notification for ${user.name}:`, emailError); + // Don't show error to user - email failure shouldn't disrupt the main operation + } } } catch (error) { console.error(`Failed to process officer for user ${user.name}:`, error); @@ -679,8 +741,28 @@ export default function OfficerManagement() { } try { + // Store previous values for the email notification + const previousType = officerToEdit.type; + await updateService.updateField(Collections.OFFICERS, officerId, 'type', newType); + // Send email notification for role type change (non-blocking) + try { + await EmailClient.notifyOfficerRoleChange( + officerId, + officerToEdit.role, // Role stays the same + previousType, + officerToEdit.role, // Role stays the same + newType, + currentUserId || undefined, + false // This is an update, not a new officer + ); + console.log(`Officer type change notification sent for ${officerToEdit.expand?.user.name}`); + } catch (emailError) { + console.error(`Failed to send officer type change notification for ${officerToEdit.expand?.user.name}:`, emailError); + // Don't show error to user - email failure shouldn't disrupt the main operation + } + toast.success('Officer updated successfully'); // Refresh officers list diff --git a/src/pages/api/email/send-officer-notification.ts b/src/pages/api/email/send-officer-notification.ts new file mode 100644 index 0000000..fb7ca88 --- /dev/null +++ b/src/pages/api/email/send-officer-notification.ts @@ -0,0 +1,155 @@ +import type { APIRoute } from 'astro'; +import { OfficerEmailNotifications } from '../../../scripts/email/OfficerEmailNotifications'; +import type { OfficerRoleChangeEmailData } from '../../../scripts/email/OfficerEmailNotifications'; +import { initializeEmailServices, authenticatePocketBase } from '../../../scripts/email/EmailHelpers'; +import { Collections } from '../../../schemas/pocketbase'; +import type { User, Officer } from '../../../schemas/pocketbase/schema'; + +export const POST: APIRoute = async ({ request }) => { + try { + console.log('📨 Officer notification email API called'); + + const requestData = await request.json(); + const { + type, + officerId, + additionalContext, + authData + } = requestData; + + console.log('📋 Request data:', { + type, + officerId, + hasAdditionalContext: !!additionalContext, + hasAuthData: !!authData + }); + + if (type !== 'officer_role_change') { + console.error('❌ Invalid notification type for officer endpoint:', type); + return new Response( + JSON.stringify({ error: `Invalid notification type: ${type}` }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + if (!officerId) { + console.error('❌ Missing required parameter: officerId'); + return new Response( + JSON.stringify({ error: 'Missing required parameter: officerId' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Initialize services - this creates a fresh PocketBase instance for server-side use + const { pb } = await initializeEmailServices(); + + // Authenticate with PocketBase if auth data is provided + authenticatePocketBase(pb, authData); + + const emailService = OfficerEmailNotifications.getInstance(); + + // Get the officer record with user data + console.log('🔍 Fetching officer data...'); + const officer = await pb.collection(Collections.OFFICERS).getOne(officerId, { + expand: 'user' + }) as Officer & { expand?: { user: User } }; + + if (!officer) { + console.error('❌ Officer not found:', officerId); + return new Response( + JSON.stringify({ error: 'Officer not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Get the user data from the expanded relation + const user = officer.expand?.user; + + if (!user) { + console.error('❌ User data not found for officer:', officerId); + return new Response( + JSON.stringify({ error: 'User data not found for officer' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Extract additional context data + const { + previousRole, + previousType, + newRole, + newType, + changedByUserId, + isNewOfficer + } = additionalContext || {}; + + // Get the name of the person who made the change + let changedByName = ''; + if (changedByUserId) { + try { + const changedByUser = await pb.collection(Collections.USERS).getOne(changedByUserId) as User; + changedByName = changedByUser?.name || 'Unknown User'; + } catch (error) { + console.warn('Could not fetch changed by user name:', error); + changedByName = 'Unknown User'; + } + } + + // Prepare email data + const emailData: OfficerRoleChangeEmailData = { + user, + officer, + previousRole, + previousType, + newRole: newRole || officer.role, + newType: newType || officer.type, + changedBy: changedByName, + isNewOfficer: isNewOfficer || false + }; + + console.log('📧 Sending officer role change notification...'); + console.log('📧 Email data:', { + userName: user.name, + userEmail: user.email, + officerRole: emailData.newRole, + officerType: emailData.newType, + previousRole: emailData.previousRole, + previousType: emailData.previousType, + changedBy: emailData.changedBy, + isNewOfficer: emailData.isNewOfficer + }); + + const success = await emailService.sendRoleChangeNotification(emailData); + + if (success) { + console.log('✅ Officer role change notification sent successfully'); + return new Response( + JSON.stringify({ + success: true, + message: 'Officer role change notification sent successfully' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } else { + console.error('❌ Failed to send officer role change notification'); + return new Response( + JSON.stringify({ + success: false, + error: 'Failed to send officer role change notification' + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + + } catch (error) { + console.error('❌ Error in officer notification API:', error); + return new Response( + JSON.stringify({ + success: false, + error: 'Internal server error', + details: error instanceof Error ? error.message : 'Unknown error' + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; \ No newline at end of file diff --git a/src/pages/api/email/send-reimbursement-notification.ts b/src/pages/api/email/send-reimbursement-notification.ts index 148a697..18f55d4 100644 --- a/src/pages/api/email/send-reimbursement-notification.ts +++ b/src/pages/api/email/send-reimbursement-notification.ts @@ -24,6 +24,7 @@ export const POST: APIRoute = async ({ request }) => { // Determine which endpoint to redirect to based on email type const reimbursementTypes = ['status_change', 'comment', 'submission', 'test']; const eventRequestTypes = ['event_request_submission', 'event_request_status_change', 'pr_completed', 'design_pr_notification']; + const officerTypes = ['officer_role_change']; let targetEndpoint = ''; @@ -43,6 +44,15 @@ export const POST: APIRoute = async ({ request }) => { ); } targetEndpoint = '/api/email/send-event-request-email'; + } else if (officerTypes.includes(type)) { + const { officerId } = requestData; + if (!officerId) { + return new Response( + JSON.stringify({ error: 'Missing officerId for officer notification' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + targetEndpoint = '/api/email/send-officer-notification'; } else { console.error('❌ Unknown notification type:', type); return new Response( diff --git a/src/scripts/email/EmailClient.ts b/src/scripts/email/EmailClient.ts index cfc7bf1..9d740c0 100644 --- a/src/scripts/email/EmailClient.ts +++ b/src/scripts/email/EmailClient.ts @@ -6,9 +6,10 @@ import { Authentication } from '../pocketbase/Authentication'; interface EmailNotificationRequest { - type: 'status_change' | 'comment' | 'submission' | 'test' | 'event_request_submission' | 'event_request_status_change' | 'pr_completed' | 'design_pr_notification'; + type: 'status_change' | 'comment' | 'submission' | 'test' | 'event_request_submission' | 'event_request_status_change' | 'pr_completed' | 'design_pr_notification' | 'officer_role_change'; reimbursementId?: string; eventRequestId?: string; + officerId?: string; previousStatus?: string; newStatus?: string; changedByUserId?: string; @@ -74,6 +75,36 @@ export class EmailClient { } } + private static async sendOfficerNotification(request: EmailNotificationRequest): Promise { + try { + const authData = this.getAuthData(); + const requestWithAuth = { + ...request, + authData + }; + + const response = await fetch('/api/email/send-officer-notification', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestWithAuth), + }); + + const result: EmailNotificationResponse = await response.json(); + + if (!response.ok) { + console.error('Officer notification API error:', result.error || result.message); + return false; + } + + return result.success; + } catch (error) { + console.error('Failed to send officer notification:', error); + return false; + } + } + /** * Send status change notification */ @@ -204,4 +235,30 @@ export class EmailClient { additionalContext: { action } }); } + + /** + * Send officer role change notification + */ + static async notifyOfficerRoleChange( + officerId: string, + previousRole?: string, + previousType?: string, + newRole?: string, + newType?: string, + changedByUserId?: string, + isNewOfficer?: boolean + ): Promise { + return this.sendOfficerNotification({ + type: 'officer_role_change', + officerId, + additionalContext: { + previousRole, + previousType, + newRole, + newType, + changedByUserId, + isNewOfficer + } + }); + } } \ No newline at end of file diff --git a/src/scripts/email/OfficerEmailNotifications.ts b/src/scripts/email/OfficerEmailNotifications.ts new file mode 100644 index 0000000..1625fa7 --- /dev/null +++ b/src/scripts/email/OfficerEmailNotifications.ts @@ -0,0 +1,296 @@ +import { Resend } from 'resend'; +import type { User, Officer } from '../../schemas/pocketbase/schema'; +import { OfficerTypes } from '../../schemas/pocketbase'; + +// Email template data interfaces +export interface OfficerRoleChangeEmailData { + user: User; + officer: Officer; + previousRole?: string; + previousType?: string; + newRole: string; + newType: string; + changedBy?: string; + isNewOfficer?: boolean; // If this is a new officer appointment +} + +export class OfficerEmailNotifications { + private resend: Resend; + private fromEmail: string; + private replyToEmail: string; + + constructor() { + // Initialize Resend with API key from environment + const apiKey = import.meta.env.RESEND_API_KEY; + + if (!apiKey) { + throw new Error('RESEND_API_KEY environment variable is required'); + } + + this.resend = new Resend(apiKey); + this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD '; + this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu'; + } + + private static instance: OfficerEmailNotifications | null = null; + + public static getInstance(): OfficerEmailNotifications { + if (!OfficerEmailNotifications.instance) { + OfficerEmailNotifications.instance = new OfficerEmailNotifications(); + } + return OfficerEmailNotifications.instance; + } + + /** + * Send officer role change notification email + */ + async sendRoleChangeNotification(data: OfficerRoleChangeEmailData): Promise { + try { + const { user, officer, previousRole, previousType, newRole, newType, changedBy, isNewOfficer } = data; + + const subject = isNewOfficer + ? `Welcome to IEEE UCSD Leadership - ${newRole}` + : `Your IEEE UCSD Officer Role has been Updated`; + + const typeColor = this.getOfficerTypeColor(newType); + const typeText = this.getOfficerTypeDisplayName(newType); + + const html = ` + + + + + + ${subject} + + +
+

IEEE UCSD Officer Update

+
+ +
+

+ ${isNewOfficer ? 'Welcome to the Team!' : 'Role Update'} +

+

Hello ${user.name},

+ + ${isNewOfficer ? ` +

Congratulations! You have been appointed as an officer for IEEE UCSD. We're excited to have you join our leadership team!

+ ` : ` +

Your officer role has been updated in the IEEE UCSD system.

+ `} + +
+
+

Your Current Role

+
+ ${newRole} + ${typeText} +
+
+ + ${!isNewOfficer && (previousRole || previousType) ? ` +
+ Previous: ${previousRole || 'Unknown Role'} (${this.getOfficerTypeDisplayName(previousType || '')}) +
+ ` : ''} + + ${changedBy ? ` +
+ ${isNewOfficer ? 'Appointed' : 'Updated'} by: ${changedBy} +
+ ` : ''} +
+ +
+

Officer Information

+ + + + + + + + + + + + + + + + + +
Name:${user.name}
Email:${user.email}
Role:${newRole}
Officer Type:${typeText}
+
+ + ${this.getOfficerTypeDescription(newType)} + +
+

Next Steps:

+
    +
  • Check your access to the officer dashboard
  • +
  • Familiarize yourself with your new responsibilities
  • +
  • Reach out to other officers if you have questions
  • + ${isNewOfficer ? '
  • Attend the next officer meeting to get up to speed
  • ' : ''} +
+
+
+ +
+

This is an automated notification from IEEE UCSD Officer Management System.

+

If you have any questions about your role, please contact us at ${this.replyToEmail}

+
+ + + `; + + const result = await this.resend.emails.send({ + from: this.fromEmail, + to: [user.email], + replyTo: this.replyToEmail, + subject, + html, + }); + + console.log('Officer role change email sent successfully:', result); + return true; + } catch (error) { + console.error('Failed to send officer role change email:', error); + return false; + } + } + + /** + * Get color for officer type badge + */ + private getOfficerTypeColor(type: string): string { + switch (type) { + case OfficerTypes.ADMINISTRATOR: + return '#dc3545'; // Red for admin + case OfficerTypes.EXECUTIVE: + return '#6f42c1'; // Purple for executive + case OfficerTypes.GENERAL: + return '#007bff'; // Blue for general + case OfficerTypes.HONORARY: + return '#fd7e14'; // Orange for honorary + case OfficerTypes.PAST: + return '#6c757d'; // Gray for past + default: + return '#28a745'; // Green as default + } + } + + /** + * Get display name for officer type + */ + private getOfficerTypeDisplayName(type: string): string { + switch (type) { + case OfficerTypes.ADMINISTRATOR: + return 'Administrator'; + case OfficerTypes.EXECUTIVE: + return 'Executive Officer'; + case OfficerTypes.GENERAL: + return 'General Officer'; + case OfficerTypes.HONORARY: + return 'Honorary Officer'; + case OfficerTypes.PAST: + return 'Past Officer'; + default: + return 'Officer'; + } + } + + /** + * Get description for officer type + */ + private getOfficerTypeDescription(type: string): string { + const baseStyle = "background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;"; + + switch (type) { + case OfficerTypes.ADMINISTRATOR: + return ` +
+

Administrator Role:

+

+ As an administrator, you have full access to manage officers, events, and system settings. You can add/remove other officers and access all administrative features. +

+
+ `; + case OfficerTypes.EXECUTIVE: + return ` +
+

Executive Officer Role:

+

+ As an executive officer, you have leadership responsibilities and access to advanced features in the officer dashboard. You can manage events and participate in key decision-making. +

+
+ `; + case OfficerTypes.GENERAL: + return ` +
+

General Officer Role:

+

+ As a general officer, you have access to the officer dashboard and can help with event management, member engagement, and other organizational activities. +

+
+ `; + case OfficerTypes.HONORARY: + return ` +
+

Honorary Officer Role:

+

+ As an honorary officer, you are recognized for your contributions to IEEE UCSD. You have access to officer resources and are part of our leadership community. +

+
+ `; + case OfficerTypes.PAST: + return ` +
+

Past Officer Status:

+

+ Thank you for your service to IEEE UCSD! As a past officer, you maintain access to alumni resources and remain part of our leadership community. +

+
+ `; + default: + return ` +
+

Officer Role:

+

+ Welcome to the IEEE UCSD officer team! You now have access to officer resources and can contribute to our organization's activities. +

+
+ `; + } + } + + /** + * Batch notify multiple officers (for bulk operations) + */ + async notifyBulkRoleChanges( + notifications: OfficerRoleChangeEmailData[] + ): Promise<{ successful: number; failed: number }> { + let successful = 0; + let failed = 0; + + for (const notification of notifications) { + try { + const result = await this.sendRoleChangeNotification(notification); + if (result) { + successful++; + } else { + failed++; + } + + // Add a small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + console.error('Failed to send bulk notification:', error); + failed++; + } + } + + return { successful, failed }; + } +} \ No newline at end of file