New public shifts system
This commit is contained in:
271
map/app/controllers/publicShiftsController.js
Normal file
271
map/app/controllers/publicShiftsController.js
Normal file
@@ -0,0 +1,271 @@
|
||||
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)
|
||||
};
|
||||
Reference in New Issue
Block a user