const nocodbService = require('../services/nocodb'); const config = require('../config'); const logger = require('../utils/logger'); const { extractId } = require('../utils/helpers'); class ShiftsController { // Get all shifts (public) async getAll(req, res) { try { if (!config.nocodb.shiftsSheetId) { return res.status(500).json({ success: false, error: 'Shifts not configured' }); } logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId); const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, { sort: 'Date,Start Time' }); let shifts = (response.list || []).filter(shift => shift.Status !== 'Cancelled' ); // If signups sheet is configured, calculate current volunteer counts if (config.nocodb.shiftSignupsSheetId) { try { const signupsResponse = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const allSignups = signupsResponse.list || []; // Update each shift with calculated volunteer count shifts = shifts.map(shift => { const confirmedSignups = allSignups.filter(signup => signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed' ); const currentVolunteers = confirmedSignups.length; const maxVolunteers = shift['Max Volunteers'] || 0; return { ...shift, 'Current Volunteers': currentVolunteers, 'Status': currentVolunteers >= maxVolunteers ? 'Full' : 'Open' }; }); } catch (signupError) { logger.warn('Could not load signups for volunteer count calculation:', signupError); } } res.json({ success: true, shifts: shifts }); } catch (error) { logger.error('Error fetching shifts:', error); res.status(500).json({ success: false, error: 'Failed to fetch shifts' }); } } // Get user's signups async getUserSignups(req, res) { try { const userEmail = req.session.userEmail; // Check if shift signups sheet is configured if (!config.nocodb.shiftSignupsSheetId) { logger.warn('Shift signups sheet not configured'); return res.json({ success: true, signups: [] }); } // Load all signups and filter in JavaScript const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId, { sort: '-Signup Date' }); logger.info('All signups loaded:', allSignups); logger.info('Filtering for user:', userEmail); // Filter for this user's confirmed signups const userSignups = (allSignups.list || []).filter(signup => { logger.debug('Checking signup:', signup); // NocoDB returns fields with title case const email = signup['User Email']; const status = signup.Status; logger.debug(`Comparing: email="${email}" vs userEmail="${userEmail}", status="${status}"`); return email === userEmail && status === 'Confirmed'; }); logger.info('User signups found:', userSignups); // Transform to match expected format in frontend const transformedSignups = userSignups.map(signup => ({ id: signup.ID || signup.id, shift_id: signup['Shift ID'], shift_title: signup['Shift Title'], user_email: signup['User Email'], user_name: signup['User Name'], signup_date: signup['Signup Date'], status: signup.Status })); res.json({ success: true, signups: transformedSignups }); } catch (error) { logger.error('Error fetching user signups:', error); // Don't fail, just return empty array res.json({ success: true, signups: [] }); } } // Sign up for a shift async signup(req, res) { try { if (!config.nocodb.shiftsSheetId) { return res.status(400).json({ success: false, error: 'Shifts sheet not configured' }); } if (!config.nocodb.shiftSignupsSheetId) { return res.status(400).json({ success: false, error: 'Shift signups sheet not configured' }); } const { shiftId } = req.params; const userEmail = req.session.userEmail; const userName = req.session.userName || userEmail; logger.info(`User ${userEmail} attempting to sign up for shift ${shiftId}`); // Check if shift exists and is open const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); if (!shift || shift.Status === 'Cancelled') { return res.status(400).json({ success: false, error: 'Shift not available' }); } // Calculate current volunteers dynamically let currentVolunteers = 0; let allSignups = { list: [] }; // Initialize with empty list if (config.nocodb.shiftSignupsSheetId) { allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const confirmedSignups = (allSignups.list || []).filter(signup => signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed' ); currentVolunteers = confirmedSignups.length; } if (currentVolunteers >= shift['Max Volunteers']) { return res.status(400).json({ success: false, error: 'Shift is full' }); } // Check if already signed up - we already have allSignups from above const existingSignup = (allSignups.list || []).find(signup => { return signup['Shift ID'] === parseInt(shiftId) && signup['User Email'] === userEmail && signup.Status === 'Confirmed'; }); if (existingSignup) { return res.status(400).json({ success: false, error: 'Already signed up for this shift' }); } // Create signup const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, { 'Shift ID': parseInt(shiftId), 'Shift Title': shift.Title, 'User Email': userEmail, 'User Name': userName, 'Signup Date': new Date().toISOString(), 'Status': 'Confirmed' }); logger.info('Created signup:', signup); // Update shift volunteer count with calculated value await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { 'Current Volunteers': currentVolunteers + 1, 'Status': currentVolunteers + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open' }); res.json({ success: true, message: 'Successfully signed up for shift' }); } catch (error) { logger.error('Error signing up for shift:', error); res.status(500).json({ success: false, error: 'Failed to sign up for shift' }); } } // Cancel shift signup async cancelSignup(req, res) { try { if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) { return res.status(400).json({ success: false, error: 'Shifts not configured' }); } const { shiftId } = req.params; const userEmail = req.session.userEmail; logger.info(`User ${userEmail} attempting to cancel signup for shift ${shiftId}`); // Find the signup const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const signup = (allSignups.list || []).find(s => { return s['Shift ID'] === parseInt(shiftId) && s['User Email'] === userEmail && s.Status === 'Confirmed'; }); if (!signup) { return res.status(404).json({ success: false, error: 'Signup not found' }); } // Update signup status to cancelled await nocodbService.update(config.nocodb.shiftSignupsSheetId, signup.ID || signup.id, { 'Status': 'Cancelled' }); // Calculate current volunteers dynamically after cancellation const allSignupsAfter = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const confirmedSignupsAfter = (allSignupsAfter.list || []).filter(s => s['Shift ID'] === parseInt(shiftId) && s.Status === 'Confirmed' ); const newCount = confirmedSignupsAfter.length; const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { 'Current Volunteers': newCount, 'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open' }); res.json({ success: true, message: 'Successfully cancelled signup' }); } catch (error) { logger.error('Error cancelling signup:', error); res.status(500).json({ success: false, error: 'Failed to cancel signup' }); } } // Admin: Create shift async create(req, res) { try { const { title, description, date, startTime, endTime, location, maxVolunteers, isPublic } = req.body; if (!title || !date || !startTime || !endTime || !location || !maxVolunteers) { return res.status(400).json({ success: false, error: 'Missing required fields' }); } const shift = await nocodbService.create(config.nocodb.shiftsSheetId, { Title: title, Description: description, Date: date, 'Start Time': startTime, 'End Time': endTime, Location: location, 'Max Volunteers': parseInt(maxVolunteers), 'Current Volunteers': 0, Status: 'Open', 'Is Public': isPublic !== false, // Default to true if not specified 'Created By': req.session.userEmail, 'Created At': new Date().toISOString(), 'Updated At': new Date().toISOString() }); res.json({ success: true, shift }); } catch (error) { logger.error('Error creating shift:', error); res.status(500).json({ success: false, error: 'Failed to create shift' }); } } // Admin: Update shift async update(req, res) { try { const { id } = req.params; const updateData = {}; // Map fields that can be updated const fieldMap = { title: 'Title', description: 'Description', date: 'Date', startTime: 'Start Time', endTime: 'End Time', location: 'Location', maxVolunteers: 'Max Volunteers', status: 'Status', isPublic: 'Is Public' }; for (const [key, field] of Object.entries(fieldMap)) { if (req.body[key] !== undefined) { updateData[field] = req.body[key]; } } if (updateData['Max Volunteers']) { updateData['Max Volunteers'] = parseInt(updateData['Max Volunteers']); } updateData['Updated At'] = new Date().toISOString(); const updated = await nocodbService.update(config.nocodb.shiftsSheetId, id, updateData); res.json({ success: true, shift: updated }); } catch (error) { logger.error('Error updating shift:', error); res.status(500).json({ success: false, error: 'Failed to update shift' }); } } // Admin: Delete shift async delete(req, res) { try { const { id } = req.params; // Check if signups sheet is configured if (config.nocodb.shiftSignupsSheetId) { try { // Get all signups and filter in JavaScript to avoid NocoDB query issues const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); // Filter for confirmed signups for this shift const signupsToCancel = (allSignups.list || []).filter(signup => signup['Shift ID'] === parseInt(id) && signup.Status === 'Confirmed' ); // Cancel each signup for (const signup of signupsToCancel) { await nocodbService.update(config.nocodb.shiftSignupsSheetId, signup.ID || signup.id, { Status: 'Cancelled' }); } logger.info(`Cancelled ${signupsToCancel.length} signups for shift ${id}`); } catch (signupError) { logger.error('Error cancelling signups:', signupError); // Continue with shift deletion even if signup cancellation fails } } // Delete the shift await nocodbService.delete(config.nocodb.shiftsSheetId, id); res.json({ success: true, message: 'Shift deleted successfully' }); } catch (error) { logger.error('Error deleting shift:', error); res.status(500).json({ success: false, error: 'Failed to delete shift' }); } } // Admin: Get all shifts with signup details async getAllAdmin(req, res) { try { if (!config.nocodb.shiftsSheetId) { logger.error('Shifts sheet not configured'); return res.status(500).json({ success: false, error: 'Shifts not configured' }); } logger.info('Loading admin shifts from:', config.nocodb.shiftsSheetId); let shifts; try { shifts = await nocodbService.getAll(config.nocodb.shiftsSheetId, { sort: '-Date,-Start Time' }); } catch (apiError) { logger.error('Error loading shifts from NocoDB:', apiError); // If it's a 422 error, try without sort parameters if (apiError.response?.status === 422) { logger.warn('Retrying without sort parameters due to 422 error'); try { shifts = await nocodbService.getAll(config.nocodb.shiftsSheetId); } catch (retryError) { logger.error('Retry also failed:', retryError); return res.status(500).json({ success: false, error: 'Failed to load shifts from database' }); } } else { throw apiError; } } logger.info('Loaded shifts:', shifts.list?.length || 0, 'records'); // Only try to get signups if the signups sheet is configured if (config.nocodb.shiftSignupsSheetId) { try { // Get ALL signups once instead of querying for each shift logger.info('Loading all signups once for performance optimization...'); const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); logger.info(`Loaded ${allSignups.list?.length || 0} total signups from database`); // Group signups by shift ID for efficient processing const signupsByShift = {}; (allSignups.list || []).forEach(signup => { const shiftId = parseInt(signup['Shift ID']); if (!signupsByShift[shiftId]) { signupsByShift[shiftId] = []; } // Only include confirmed signups const signupStatus = (signup.Status || '').toString().toLowerCase().trim(); const isConfirmed = signupStatus === 'confirmed' || signupStatus === 'active' || (signupStatus === '' && signup['User Email']); // Include records with empty status if they have an email if (isConfirmed) { signupsByShift[shiftId].push(signup); } }); // Assign signups to each shift for (const shift of shifts.list || []) { const shiftId = parseInt(shift.ID); shift.signups = signupsByShift[shiftId] || []; } logger.info(`Processed signups for ${Object.keys(signupsByShift).length} shifts`); } catch (signupError) { logger.error('Error loading signups:', signupError); // Set empty signups for all shifts on error for (const shift of shifts.list || []) { shift.signups = []; } } } else { logger.warn('Shift signups sheet not configured, skipping signup data'); // Set empty signups for all shifts for (const shift of shifts.list || []) { shift.signups = []; } } res.json({ success: true, shifts: shifts.list || [] }); } catch (error) { logger.error('Error fetching admin shifts:', error); res.status(500).json({ success: false, error: 'Failed to fetch shifts' }); } } // Admin: Add user to shift async addUserToShift(req, res) { try { const { shiftId } = req.params; const { userEmail } = req.body; if (!userEmail) { return res.status(400).json({ success: false, error: 'User email is required' }); } if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) { return res.status(500).json({ success: false, error: 'Shifts not properly configured' }); } // Get shift details const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); if (!shift) { return res.status(404).json({ success: false, error: 'Shift not found' }); } // Check if user exists const user = await nocodbService.getUserByEmail(userEmail); if (!user) { return res.status(404).json({ success: false, error: 'User not found' }); } // Check if user is already signed up const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const existingSignup = (allSignups.list || []).find(signup => { return signup['Shift ID'] === parseInt(shiftId) && signup['User Email'] === userEmail && signup.Status === 'Confirmed'; }); if (existingSignup) { return res.status(400).json({ success: false, error: 'User is already signed up for this shift' }); } // Check capacity const confirmedSignups = (allSignups.list || []).filter(signup => signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed' ); if (confirmedSignups.length >= shift['Max Volunteers']) { return res.status(400).json({ success: false, error: 'Shift is at maximum capacity' }); } // Create signup const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, { 'Shift ID': parseInt(shiftId), 'Shift Title': shift.Title, 'User Email': userEmail, 'User Name': user.Name || user.name || userEmail, 'Signup Date': new Date().toISOString(), 'Status': 'Confirmed' }); // Update shift volunteer count const newCount = confirmedSignups.length + 1; await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { 'Current Volunteers': newCount, 'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open' }); res.json({ success: true, message: 'User successfully added to shift', signup: signup }); } catch (error) { logger.error('Error adding user to shift:', error); res.status(500).json({ success: false, error: 'Failed to add user to shift' }); } } // Admin: Remove user from shift async removeUserFromShift(req, res) { try { const { shiftId, userId } = req.params; if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) { return res.status(500).json({ success: false, error: 'Shifts not properly configured' }); } // Find the signup by user ID (signup record ID) const signup = await nocodbService.getById(config.nocodb.shiftSignupsSheetId, userId); if (!signup) { return res.status(404).json({ success: false, error: 'Signup not found' }); } // Verify the signup belongs to the specified shift if (signup['Shift ID'] !== parseInt(shiftId)) { return res.status(400).json({ success: false, error: 'Signup does not belong to this shift' }); } // Update signup status to cancelled await nocodbService.update(config.nocodb.shiftSignupsSheetId, userId, { 'Status': 'Cancelled' }); // Update shift volunteer count const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const confirmedSignups = (allSignups.list || []).filter(s => s['Shift ID'] === parseInt(shiftId) && s.Status === 'Confirmed' ); const newCount = confirmedSignups.length; const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { 'Current Volunteers': newCount, 'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open' }); res.json({ success: true, message: 'User successfully removed from shift' }); } catch (error) { logger.error('Error removing user from shift:', error); res.status(500).json({ success: false, error: 'Failed to remove user from shift' }); } } // Admin: Email shift details to all volunteers async emailShiftDetails(req, res) { try { const { shiftId } = req.params; if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) { return res.status(500).json({ success: false, error: 'Shifts not properly configured' }); } // Get shift details const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); if (!shift) { return res.status(404).json({ success: false, error: 'Shift not found' }); } // Get all confirmed signups for this shift const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const shiftSignups = (allSignups.list || []).filter(signup => signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed' ); if (shiftSignups.length === 0) { return res.status(400).json({ success: false, error: 'No volunteers signed up for this shift' }); } // Import email service const { sendEmail } = require('../services/email'); const emailTemplates = require('../services/emailTemplates'); const config_app = require('../config'); // Prepare email template variables const shiftDate = new Date(shift.Date); const baseUrl = config_app.isProduction ? `https://map.${config_app.domain}` : `http://localhost:${config_app.port}`; const hasDescription = shift.Description && shift.Description.trim().length > 0; const templateVariables = { APP_NAME: 'Volunteer Shift Manager', SHIFT_TITLE: shift.Title, SHIFT_DATE: shiftDate.toLocaleDateString(), SHIFT_START_TIME: shift['Start Time'], SHIFT_END_TIME: shift['End Time'], SHIFT_LOCATION: shift.Location || 'TBD', CURRENT_VOLUNTEERS: shiftSignups.length, MAX_VOLUNTEERS: shift['Max Volunteers'], SHIFT_STATUS: shift.Status || 'Open', SHIFT_STATUS_CLASS: (shift.Status || 'Open').toLowerCase(), SHIFT_DESCRIPTION: shift.Description || '', SHIFT_DESCRIPTION_SECTION: hasDescription ? `ADDITIONAL INFORMATION:\n======================\n${shift.Description}` : '', DESCRIPTION_DISPLAY: hasDescription ? 'block' : 'none', TIMESTAMP: new Date().toLocaleString() }; // Send emails to all volunteers const emailResults = []; const failedEmails = []; for (const signup of shiftSignups) { try { const userVariables = { ...templateVariables, USER_NAME: signup['User Name'] || signup['User Email'], USER_EMAIL: signup['User Email'] }; const emailContent = await emailTemplates.render('shift-details', userVariables); await sendEmail({ to: signup['User Email'], subject: `Shift Details: ${shift.Title} - ${shiftDate.toLocaleDateString()}`, text: emailContent.text, html: emailContent.html }); emailResults.push({ email: signup['User Email'], name: signup['User Name'], success: true }); logger.info(`Sent shift details email to: ${signup['User Email']}`); } catch (emailError) { logger.error(`Failed to send shift details email to ${signup['User Email']}:`, emailError); failedEmails.push({ email: signup['User Email'], name: signup['User Name'], error: emailError.message }); } } const successCount = emailResults.length; const failCount = failedEmails.length; if (successCount === 0) { return res.status(500).json({ success: false, error: 'Failed to send any emails', details: failedEmails }); } res.json({ success: true, message: `Sent shift details to ${successCount} volunteer${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`, results: { successful: emailResults, failed: failedEmails, shift: { id: shiftId, title: shift.Title, date: shiftDate.toLocaleDateString(), volunteers: shiftSignups.length } } }); } catch (error) { logger.error('Error sending shift details emails:', error); res.status(500).json({ success: false, error: 'Failed to send shift details emails' }); } } } module.exports = new ShiftsController();