diff --git a/map/app/config/index.js b/map/app/config/index.js index 58a74ef..6c6eb96 100644 --- a/map/app/config/index.js +++ b/map/app/config/index.js @@ -52,6 +52,28 @@ if (process.env.NOCODB_SETTINGS_SHEET) { } } +// Parse shifts sheet ID +let shiftsSheetId = null; +if (process.env.NOCODB_SHIFTS_SHEET) { + if (process.env.NOCODB_SHIFTS_SHEET.startsWith('http')) { + const { tableId } = parseNocoDBUrl(process.env.NOCODB_SHIFTS_SHEET); + shiftsSheetId = tableId; + } else { + shiftsSheetId = process.env.NOCODB_SHIFTS_SHEET; + } +} + +// Parse shift signups sheet ID +let shiftSignupsSheetId = null; +if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET) { + if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET.startsWith('http')) { + const { tableId } = parseNocoDBUrl(process.env.NOCODB_SHIFT_SIGNUPS_SHEET); + shiftSignupsSheetId = tableId; + } else { + shiftSignupsSheetId = process.env.NOCODB_SHIFT_SIGNUPS_SHEET; + } +} + module.exports = { // Server config port: process.env.PORT || 3000, @@ -66,7 +88,9 @@ module.exports = { tableId: process.env.NOCODB_TABLE_ID || parsedIds.tableId, loginSheetId, settingsSheetId, - viewUrl: process.env.NOCODB_VIEW_URL + viewUrl: process.env.NOCODB_VIEW_URL, + shiftsSheetId, + shiftSignupsSheetId }, // Session config diff --git a/map/app/controllers/shiftsController.js b/map/app/controllers/shiftsController.js new file mode 100644 index 0000000..51d37f2 --- /dev/null +++ b/map/app/controllers/shiftsController.js @@ -0,0 +1,461 @@ +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); + + // Load all shifts without filter - we'll filter in JavaScript + const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, { + sort: 'Date,Start Time' + }); + + logger.info('Loaded shifts:', response); + + // Filter out cancelled shifts manually + const shifts = (response.list || []).filter(shift => + shift.Status !== 'Cancelled' + ); + + 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.getAll(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'], + 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' + }); + } + + if (shift['Current Volunteers'] >= shift['Max Volunteers']) { + return res.status(400).json({ + success: false, + error: 'Shift is full' + }); + } + + // Check if already signed up - get all signups and filter + const allSignups = await nocodbService.getAll(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: 'Already signed up for this shift' + }); + } + + // Create signup + const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, { + 'Shift ID': parseInt(shiftId), + 'User Email': userEmail, + 'User Name': userName, + 'Signup Date': new Date().toISOString(), + 'Status': 'Confirmed' + }); + + logger.info('Created signup:', signup); + + // Update shift volunteer count + await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, { + 'Current Volunteers': (shift['Current Volunteers'] || 0) + 1, + 'Status': shift['Current Volunteers'] + 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.getAll(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' + }); + + // Update shift volunteer count + const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId); + const newCount = Math.max(0, (shift['Current Volunteers'] || 0) - 1); + + 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 } = 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', + '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' + }; + + 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.getAll(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); + + // Only try to get signups if the signups sheet is configured + if (config.nocodb.shiftSignupsSheetId) { + // Get signup counts for each shift + for (const shift of shifts.list || []) { + try { + const signups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId); + // Filter signups for this shift manually + const shiftSignups = (signups.list || []).filter(signup => + signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed' + ); + shift.signups = shiftSignups; + } catch (signupError) { + logger.error(`Error loading signups for shift ${shift.ID}:`, signupError); + 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' + }); + } + } +} + +module.exports = new ShiftsController(); \ No newline at end of file diff --git a/map/app/public/admin.html b/map/app/public/admin.html index 83beaae..bac2651 100644 --- a/map/app/public/admin.html +++ b/map/app/public/admin.html @@ -33,10 +33,11 @@
No shifts created yet.
'; + return; + } + + list.innerHTML = shifts.map(shift => { + const shiftDate = new Date(shift.Date); + const signupCount = shift.signups ? shift.signups.length : 0; + + return ` +📅 ${shiftDate.toLocaleDateString()} | ⏰ ${shift['Start Time']} - ${shift['End Time']}
+📍 ${escapeHtml(shift.Location || 'TBD')}
+👥 ${signupCount}/${shift['Max Volunteers']} volunteers
+${shift.Status || 'Open'}
+No shifts available at this time.
'; + return; + } + + grid.innerHTML = shifts.map(shift => { + const shiftDate = new Date(shift.Date); + const isSignedUp = mySignups.some(s => s.shift_id === shift.ID); + const isFull = shift['Current Volunteers'] >= shift['Max Volunteers']; + + return ` +📅 ${shiftDate.toLocaleDateString()}
+⏰ ${shift['Start Time']} - ${shift['End Time']}
+📍 ${escapeHtml(shift.Location || 'TBD')}
+👥 ${shift['Current Volunteers']}/${shift['Max Volunteers']} volunteers
+You haven\'t signed up for any shifts yet.
'; + return; + } + + // Need to match signups with shift details + const signupsWithDetails = mySignups.map(signup => { + const shift = allShifts.find(s => s.ID === signup.shift_id); + return { ...signup, shift }; + }).filter(s => s.shift); + + list.innerHTML = signupsWithDetails.map(signup => { + const shiftDate = new Date(signup.shift.Date); + return ` +📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}
+