freealberta/map/app/controllers/publicShiftsController.js
2025-08-22 14:45:40 -06:00

271 lines
10 KiB
JavaScript

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