const nocodbService = require('../services/nocodb'); const config = require('../config'); const logger = require('../utils/logger'); const { sendEmail } = require('../services/email'); const emailTemplates = require('../services/emailTemplates'); const crypto = require('crypto'); class PublicShiftsController { // Get all public shifts (without volunteer counts) async getPublicShifts(req, res) { try { if (!config.nocodb.shiftsSheetId) { return res.status(500).json({ success: false, error: 'Shifts not configured' }); } const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, { sort: 'Date,Start Time' }); // More flexible filtering - check for Public field being truthy or not explicitly false const shifts = (response.list || []).filter(shift => { // Skip cancelled shifts if (shift.Status === 'Cancelled') { return false; } // If Public field doesn't exist, include the shift (backwards compatibility) if (shift.Public === undefined || shift.Public === null) { logger.info(`Shift ${shift.Title} has no Public field, including it`); return true; } // Check for various truthy values (true, "true", 1, "1", "yes", etc.) const publicValue = String(shift.Public).toLowerCase(); return publicValue === 'true' || publicValue === '1' || publicValue === 'yes'; }); logger.info(`Found ${shifts.length} public shifts out of ${response.list?.length || 0} total`); res.json({ success: true, shifts: shifts }); } catch (error) { logger.error('Error fetching public shifts:', error); res.status(500).json({ success: false, error: 'Failed to fetch shifts' }); } } // Get single shift details for direct linking async getShiftById(req, res) { try { const { id } = req.params; const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, id); if (!shift || shift.Status === 'Cancelled') { return res.status(404).json({ success: false, error: 'Shift not found' }); } // Similar flexible check for single shift if (shift.Public !== undefined && shift.Public !== null) { const publicValue = String(shift.Public).toLowerCase(); const isPublic = publicValue === 'true' || publicValue === '1' || publicValue === 'yes'; if (!isPublic) { return res.status(404).json({ success: false, error: 'Shift not found' }); } } res.json({ success: true, shift }); } catch (error) { logger.error('Error fetching shift:', error); res.status(500).json({ success: false, error: 'Failed to fetch shift' }); } } // Public signup - creates temp user and signs them up async publicSignup(req, res) { try { const { id } = req.params; const { email, name, phone } = req.body; if (!email || !name) { return res.status(400).json({ success: false, error: 'Email and name are required' }); } // Get shift details const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, id); logger.info('Raw shift data retrieved:', JSON.stringify(shift, null, 2)); if (!shift || shift.Status === 'Cancelled') { return res.status(404).json({ success: false, error: 'Shift not found or cancelled' }); } // Check if shift is full if (shift['Current Volunteers'] >= shift['Max Volunteers']) { return res.status(400).json({ success: false, error: 'This shift is full' }); } // Check if user exists let user = await nocodbService.getUserByEmail(email); let isNewUser = false; let tempPassword = null; if (!user) { // Generate temp password using instance method const controller = new PublicShiftsController(); tempPassword = controller.generateTempPassword(); const shiftDate = new Date(shift.Date); const expiresAt = new Date(shiftDate); expiresAt.setDate(expiresAt.getDate() + 1); // Expires day after shift const userData = { Email: email, Password: tempPassword, Name: name, UserType: 'temp', 'User Type': 'temp', ExpiresAt: expiresAt.toISOString() }; logger.info('Creating temp user with data:', JSON.stringify(userData, null, 2)); user = await nocodbService.create(config.nocodb.loginSheetId, userData); isNewUser = true; logger.info(`Created temp user ${email} for shift ${id}`); } // Check if already signed up const allSignups = await nocodbService.getAllPaginated(config.nocodb.shiftSignupsSheetId); const existingSignup = (allSignups.list || []).find(s => s['Shift ID'] === parseInt(id) && s['User Email'] === email && s.Status === 'Confirmed' ); if (existingSignup) { return res.status(400).json({ success: false, error: 'You are already signed up for this shift' }); } // Create signup const signupData = { 'Shift ID': parseInt(id), 'Shift Title': shift.Title, 'User Email': email, 'User Name': name, 'Signup Date': new Date().toISOString(), 'Status': 'Confirmed', 'Source': 'public' }; // Add phone if provided if (phone && phone.trim()) { signupData['Phone'] = phone.trim(); } logger.info('Creating shift signup with data:', JSON.stringify(signupData, null, 2)); const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, signupData); // Update shift volunteer count const newCount = (shift['Current Volunteers'] || 0) + 1; await nocodbService.update(config.nocodb.shiftsSheetId, id, { 'Current Volunteers': newCount, 'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open' }); // Send confirmation email const controller = new PublicShiftsController(); await controller.sendSignupConfirmation(email, name, shift, isNewUser, tempPassword); res.json({ success: true, message: 'Successfully signed up! Check your email for confirmation and login details.', isNewUser }); } catch (error) { logger.error('Error in public signup:', error); logger.error('Error details:', error.response?.data || error.message); res.status(500).json({ success: false, error: 'Failed to complete signup. Please try again.' }); } } generateTempPassword() { // Generate readable temporary password const adjectives = ['Blue', 'Green', 'Happy', 'Swift', 'Bright']; const nouns = ['Tiger', 'Eagle', 'River', 'Mountain', 'Star']; const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const num = Math.floor(Math.random() * 100); return `${adj}${noun}${num}`; } async sendSignupConfirmation(email, name, shift, isNewUser, tempPassword) { const baseUrl = config.isProduction ? `https://map.${config.domain}` : `http://localhost:${config.port}`; const shiftDate = new Date(shift.Date); // Prepare all variables including optional ones const variables = { APP_NAME: process.env.APP_NAME || 'CMlite Map', USER_NAME: name, USER_EMAIL: email, SHIFT_TITLE: shift.Title || 'Untitled Shift', SHIFT_DATE: shiftDate.toLocaleDateString(), SHIFT_TIME: `${shift['Start Time'] || ''} - ${shift['End Time'] || ''}`, SHIFT_LOCATION: shift.Location || 'Location TBD', SHIFT_DESCRIPTION: shift.Description || '', // Include even if empty LOGIN_URL: `${baseUrl}/login.html`, SHIFTS_URL: `${baseUrl}/shifts.html`, IS_NEW_USER: isNewUser, TEMP_PASSWORD: tempPassword || '', TIMESTAMP: new Date().toLocaleString() }; // Log the variables for debugging logger.info('Email template variables:', JSON.stringify(variables, null, 2)); const templateName = isNewUser ? 'public-shift-signup-new' : 'public-shift-signup-existing'; const { html, text } = await emailTemplates.render(templateName, variables); return await sendEmail({ to: email, subject: `Shift Signup Confirmation - ${shift.Title}`, text, html }); } } // Create singleton instance and bind methods const controller = new PublicShiftsController(); module.exports = { getPublicShifts: controller.getPublicShifts.bind(controller), getShiftById: controller.getShiftById.bind(controller), publicSignup: controller.publicSignup.bind(controller) };