add officer role update

This commit is contained in:
chark1es 2025-05-29 15:40:46 -07:00
parent 40b2ea48c1
commit a57a4e6889
5 changed files with 601 additions and 1 deletions

View file

@ -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

View file

@ -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' } }
);
}
};

View file

@ -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(

View file

@ -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<boolean> {
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<boolean> {
return this.sendOfficerNotification({
type: 'officer_role_change',
officerId,
additionalContext: {
previousRole,
previousType,
newRole,
newType,
changedByUserId,
isNewOfficer
}
});
}
}

View file

@ -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 <noreply@transactional.ieeeatucsd.org>';
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<boolean> {
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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Officer Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">
${isNewOfficer ? 'Welcome to the Team!' : 'Role Update'}
</h2>
<p>Hello ${user.name},</p>
${isNewOfficer ? `
<p>Congratulations! You have been appointed as an officer for IEEE UCSD. We're excited to have you join our leadership team!</p>
` : `
<p>Your officer role has been updated in the IEEE UCSD system.</p>
`}
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${typeColor}; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<h3 style="margin: 0 0 10px 0; color: #2c3e50;">Your Current Role</h3>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-weight: bold; font-size: 18px; color: #2c3e50;">${newRole}</span>
<span style="background: ${typeColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${typeText}</span>
</div>
</div>
${!isNewOfficer && (previousRole || previousType) ? `
<div style="color: #666; font-size: 14px; padding: 10px 0; border-top: 1px solid #eee;">
<strong>Previous:</strong> ${previousRole || 'Unknown Role'} (${this.getOfficerTypeDisplayName(previousType || '')})
</div>
` : ''}
${changedBy ? `
<div style="color: #666; font-size: 14px; margin-top: 10px;">
${isNewOfficer ? 'Appointed' : 'Updated'} by: ${changedBy}
</div>
` : ''}
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Officer Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Name:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.name}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Email:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${user.email}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Role:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${newRole}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Officer Type:</td>
<td style="padding: 8px 0;">${typeText}</td>
</tr>
</table>
</div>
${this.getOfficerTypeDescription(newType)}
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Next Steps:</h4>
<ul style="margin: 0; padding-left: 20px;">
<li>Check your access to the officer dashboard</li>
<li>Familiarize yourself with your new responsibilities</li>
<li>Reach out to other officers if you have questions</li>
${isNewOfficer ? '<li>Attend the next officer meeting to get up to speed</li>' : ''}
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Officer Management System.</p>
<p>If you have any questions about your role, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
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 `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Administrator Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
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.
</p>
</div>
`;
case OfficerTypes.EXECUTIVE:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Executive Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
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.
</p>
</div>
`;
case OfficerTypes.GENERAL:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>General Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
As a general officer, you have access to the officer dashboard and can help with event management, member engagement, and other organizational activities.
</p>
</div>
`;
case OfficerTypes.HONORARY:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Honorary Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
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.
</p>
</div>
`;
case OfficerTypes.PAST:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Past Officer Status:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
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.
</p>
</div>
`;
default:
return `
<div style="${baseStyle}">
<p style="margin: 0; font-size: 14px;"><strong>Officer Role:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
Welcome to the IEEE UCSD officer team! You now have access to officer resources and can contribute to our organization's activities.
</p>
</div>
`;
}
}
/**
* 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 };
}
}