Initial v2 commit: complete rebuild with unified API + React admin

Phase 1-14 complete:
- Unified Express.js API (TypeScript, Prisma ORM, PostgreSQL 16)
- React Admin GUI (Vite + Ant Design + Zustand)
- JWT auth with refresh tokens
- Influence: Campaigns, Representatives, Responses, Email Queue
- Map: Locations, Cuts, Shifts, Canvassing System
- NAR data import infrastructure (2025 format)
- Listmonk newsletter integration
- Landing page builder (GrapesJS)
- MkDocs + Code Server integration
- Volunteer portal with GPS tracking
- Monitoring stack (Prometheus, Grafana, Alertmanager)
- Pangolin tunnel integration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 10:05:04 -07:00
commit a77306fac2
590 changed files with 159852 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const { extractId } = require('../utils/helpers');
class AuthController {
async login(req, res) {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
error: 'Invalid email format'
});
}
logger.info('Login attempt:', {
email,
ip: req.ip,
cfIp: req.headers['cf-connecting-ip'],
userAgent: req.headers['user-agent']
});
// Fetch user from NocoDB
const user = await nocodbService.getUserByEmail(email);
if (!user) {
logger.warn(`No user found with email: ${email}`);
return res.status(401).json({
success: false,
error: 'Invalid email or password'
});
}
// Check password
if (user.Password !== password && user.password !== password) {
logger.warn(`Invalid password for email: ${email}`);
return res.status(401).json({
success: false,
error: 'Invalid email or password'
});
}
// Check if temp user has expired
const userType = user['User Type'] || user.UserType || user.userType || 'user';
if (userType === 'temp') {
const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration;
if (expiration) {
const expirationDate = new Date(expiration);
const now = new Date();
if (now > expirationDate) {
logger.warn(`Expired temp user attempted login: ${email}, expired: ${expiration}`);
return res.status(401).json({
success: false,
error: 'Account has expired. Please contact an administrator.'
});
}
}
}
// Update last login time
try {
const userId = extractId(user);
await nocodbService.update(
require('../config').nocodb.loginSheetId,
userId,
{
'Last Login': new Date().toISOString(),
last_login: new Date().toISOString()
}
);
} catch (updateError) {
logger.warn('Failed to update last login time:', updateError.message);
// Don't fail the login
}
// Set session
req.session.authenticated = true;
req.session.userId = user.id || user.Id;
req.session.userEmail = user.email || user.Email; // Make sure this is set
req.session.userName = user.name || user.Name;
req.session.isAdmin = user.admin || user.Admin || false;
// More explicit userType determination with proper fallback - handle both field name variations
let sessionUserType = 'user'; // default
if (user['User Type']) {
sessionUserType = user['User Type'].toLowerCase();
} else if (user.UserType) {
sessionUserType = user.UserType.toLowerCase();
} else if (user.userType) {
sessionUserType = user.userType.toLowerCase();
} else if (req.session.isAdmin) {
sessionUserType = 'admin';
}
req.session.userType = sessionUserType;
logger.info('User logged in successfully', {
userType: req.session.userType
});
// Force session save
req.session.save((err) => {
if (err) {
logger.error('Session save error:', err);
return res.status(500).json({
success: false,
error: 'Session error. Please try again.'
});
}
logger.info(`User authenticated: ${email}, Admin: ${req.session.isAdmin}`);
res.json({
success: true,
message: 'Login successful',
user: {
email: email,
name: req.session.userName,
isAdmin: req.session.isAdmin,
userType: req.session.userType
}
});
});
} catch (error) {
logger.error('Login error:', error.message);
res.status(500).json({
success: false,
error: 'Authentication service error. Please try again later.'
});
}
}
async logout(req, res) {
req.session.destroy((err) => {
if (err) {
logger.error('Logout error:', err);
return res.status(500).json({
success: false,
error: 'Logout failed'
});
}
res.json({
success: true,
message: 'Logged out successfully'
});
});
}
async check(req, res) {
// If user is authenticated, check for temp user expiration
if (req.session?.authenticated && req.session?.userType === 'temp' && req.session?.userEmail) {
try {
const user = await nocodbService.getUserByEmail(req.session.userEmail);
if (user) {
const expiration = user.ExpiresAt || user.ExpiresAt || user.Expiration || user.expiration;
if (expiration) {
const expirationDate = new Date(expiration);
const now = new Date();
if (now > expirationDate) {
logger.warn(`Expired temp user session detected in check: ${req.session.userEmail}, expired: ${expiration}`);
// Destroy the session
req.session.destroy((err) => {
if (err) {
logger.error('Session destroy error:', err);
}
});
return res.json({
authenticated: false,
user: null,
expired: true,
message: 'Account has expired. Please contact an administrator.'
});
}
}
}
} catch (error) {
logger.error('Error checking temp user expiration in check:', error.message);
// Don't fail the check on database errors, just log it
}
}
res.json({
authenticated: req.session?.authenticated || false,
user: req.session?.authenticated ? {
email: req.session.userEmail,
name: req.session.userName,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType || 'user'
} : null
});
}
}
module.exports = new AuthController();

View File

@@ -0,0 +1,635 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const spatialUtils = require('../utils/spatial');
class CutsController {
/**
* Get all cuts - filter by public visibility for non-admins
*/
async getAll(req, res) {
try {
// Check if cuts table is configured
if (!config.nocodb.cutsSheetId) {
// Return empty list if cuts table is not configured
return res.json({ list: [] });
}
const { isAdmin } = req.user || {};
// For NocoDB v2 API, we need to get all records and filter in memory
// since the where clause syntax may be different
// Use paginated method to get ALL records (not just the default 25 limit)
const response = await nocodbService.getAllPaginated(
config.nocodb.cutsSheetId
);
// Ensure response has list property
if (!response || !response.list) {
return res.json({ list: [] });
}
// Filter results based on user permissions
if (!isAdmin) {
response.list = response.list.filter(cut => {
const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
return isPublic === true || isPublic === 1 || isPublic === '1';
});
}
logger.info(`Retrieved ${response.list?.length || 0} cuts for ${isAdmin ? 'admin' : 'user'}`);
res.json(response);
} catch (error) {
logger.error('Error fetching cuts:', error);
// Log more details about the error
if (error.response) {
logger.error('Error response:', error.response.data);
logger.error('Error status:', error.response.status);
}
res.status(500).json({
error: 'Failed to fetch cuts',
details: error.message
});
}
}
/**
* Get single cut by ID
*/
async getById(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
const response = await nocodbService.getById(
config.nocodb.cutsSheetId,
id
);
if (!response) {
return res.status(404).json({ error: 'Cut not found' });
}
// Non-admins can only access public cuts
if (!isAdmin) {
const isPublic = response.is_public || response.Is_public || response['Public Visibility'];
if (!(isPublic === true || isPublic === 1 || isPublic === '1')) {
return res.status(403).json({ error: 'Access denied' });
}
}
logger.info(`Retrieved cut: ${response.name} (ID: ${id})`);
res.json(response);
} catch (error) {
logger.error('Error fetching cut:', error);
res.status(500).json({
error: 'Failed to fetch cut',
details: error.message
});
}
}
/**
* Create new cut - admin only
*/
async create(req, res) {
try {
const { isAdmin, email } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const {
name,
description,
color = '#3388ff',
opacity = 0.3,
category,
is_public = false,
is_official = false,
geojson,
bounds,
assigned_to
} = req.body;
// Validate required fields
if (!name || !geojson) {
return res.status(400).json({
error: 'Name and geojson are required'
});
}
// Validate GeoJSON
try {
const parsedGeoJSON = JSON.parse(geojson);
if (parsedGeoJSON.type !== 'Polygon' && parsedGeoJSON.type !== 'MultiPolygon') {
return res.status(400).json({
error: 'GeoJSON must be a Polygon or MultiPolygon'
});
}
} catch (parseError) {
return res.status(400).json({
error: 'Invalid GeoJSON format'
});
}
// Validate opacity range
if (opacity < 0 || opacity > 1) {
return res.status(400).json({
error: 'Opacity must be between 0 and 1'
});
}
const cutData = {
name,
description,
color,
opacity,
category,
is_public,
is_official,
geojson,
bounds,
assigned_to,
created_by: email,
};
const response = await nocodbService.create(
config.nocodb.cutsSheetId,
cutData
);
logger.info(`Created cut: ${name} by ${email}`);
res.status(201).json(response);
} catch (error) {
logger.error('Error creating cut:', error);
res.status(500).json({
error: 'Failed to create cut',
details: error.message
});
}
}
/**
* Update cut - admin only
*/
async update(req, res) {
try {
const { isAdmin, email } = req.user || {};
const { id } = req.params;
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Check if cut exists
const existingCut = await nocodbService.getById(
config.nocodb.cutsSheetId,
id
);
if (!existingCut) {
return res.status(404).json({ error: 'Cut not found' });
}
const {
name,
description,
color,
opacity,
category,
is_public,
is_official,
geojson,
bounds,
assigned_to
} = req.body;
// Validate GeoJSON if provided
if (geojson) {
try {
const parsedGeoJSON = JSON.parse(geojson);
if (parsedGeoJSON.type !== 'Polygon' && parsedGeoJSON.type !== 'MultiPolygon') {
return res.status(400).json({
error: 'GeoJSON must be a Polygon or MultiPolygon'
});
}
} catch (parseError) {
return res.status(400).json({
error: 'Invalid GeoJSON format'
});
}
}
// Validate opacity if provided
if (opacity !== undefined && (opacity < 0 || opacity > 1)) {
return res.status(400).json({
error: 'Opacity must be between 0 and 1'
});
}
const updateData = {
updated_at: new Date().toISOString()
};
// Only include fields that are provided
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (color !== undefined) updateData.color = color;
if (opacity !== undefined) updateData.opacity = opacity;
if (category !== undefined) updateData.category = category;
if (is_public !== undefined) updateData.is_public = is_public;
if (is_official !== undefined) updateData.is_official = is_official;
if (geojson !== undefined) updateData.geojson = geojson;
if (bounds !== undefined) updateData.bounds = bounds;
if (assigned_to !== undefined) updateData.assigned_to = assigned_to;
const response = await nocodbService.update(
config.nocodb.cutsSheetId,
id,
updateData
);
logger.info(`Updated cut: ${existingCut.name} (ID: ${id}) by ${email}`);
res.json(response);
} catch (error) {
logger.error('Error updating cut:', error);
res.status(500).json({
error: 'Failed to update cut',
details: error.message
});
}
}
/**
* Delete cut - admin only
*/
async delete(req, res) {
try {
const { isAdmin, email } = req.user || {};
const { id } = req.params;
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Check if cut exists
const existingCut = await nocodbService.getById(
config.nocodb.cutsSheetId,
id
);
if (!existingCut) {
return res.status(404).json({ error: 'Cut not found' });
}
await nocodbService.delete(
config.nocodb.cutsSheetId,
id
);
logger.info(`Deleted cut: ${existingCut.name} (ID: ${id}) by ${email}`);
res.json({ message: 'Cut deleted successfully' });
} catch (error) {
logger.error('Error deleting cut:', error);
res.status(500).json({
error: 'Failed to delete cut',
details: error.message
});
}
}
/**
* Get public cuts for map display
*/
async getPublic(req, res) {
try {
// Check if cuts table is configured
if (!config.nocodb.cutsSheetId) {
logger.warn('Cuts table not configured - NOCODB_CUTS_SHEET not set');
return res.json({ list: [] });
}
logger.info(`Fetching public cuts from table ID: ${config.nocodb.cutsSheetId}`);
// Use getAllPaginated to get ALL cuts, not just first page
const response = await nocodbService.getAllPaginated(
config.nocodb.cutsSheetId
);
logger.info(`Raw response from nocodbService.getAll:`, {
hasResponse: !!response,
hasList: !!(response && response.list),
listLength: response?.list?.length || 0,
sampleData: response?.list?.[0] || null,
allFields: response?.list?.[0] ? Object.keys(response.list[0]) : []
});
// Ensure response has list property
if (!response || !response.list) {
logger.warn('No cuts found or invalid response structure');
return res.json({ list: [] });
}
// Log all cuts before filtering
logger.info(`All cuts found: ${response.list.length}`);
response.list.forEach((cut, index) => {
// Check multiple possible field names for is_public
const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
logger.info(`Cut ${index}: ${cut.name || cut.Name} - is_public: ${isPublic} (type: ${typeof isPublic})`);
logger.info(`Available fields:`, Object.keys(cut));
});
// Filter to only public cuts - handle multiple possible field names
const originalCount = response.list.length;
response.list = response.list.filter(cut => {
const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
return isPublic === true || isPublic === 1 || isPublic === '1';
});
const publicCount = response.list.length;
logger.info(`Filtered ${originalCount} total cuts to ${publicCount} public cuts`);
res.json(response);
} catch (error) {
logger.error('Error fetching public cuts:', error);
// Log more details about the error
if (error.response) {
logger.error('Error response:', error.response.data);
logger.error('Error status:', error.response.status);
}
res.status(500).json({
error: 'Failed to fetch public cuts',
details: error.message
});
}
}
/**
* Get all locations within a cut boundary - admin only
*/
async getLocationsInCut(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Get the cut
const cut = await nocodbService.getById(config.nocodb.cutsSheetId, id);
if (!cut) {
return res.status(404).json({ error: 'Cut not found' });
}
// Get all locations (use paginated to get ALL records, not just first page)
const locationsResponse = await nocodbService.getAllPaginated(config.LOCATIONS_TABLE_ID);
if (!locationsResponse || !locationsResponse.list) {
return res.json({ locations: [], statistics: { total_locations: 0 } });
}
// Apply filters from query params
const filters = {
support_level: req.query.support_level,
has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined,
sign_size: req.query.sign_size,
has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined,
has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined
};
// Filter locations within cut boundaries
const filteredLocations = spatialUtils.filterLocationsInCut(
locationsResponse.list,
cut,
filters
);
// Calculate statistics
const statistics = spatialUtils.calculateCutStatistics(filteredLocations);
const cutName = cut.name || cut.Name || cut.title || cut.Title || 'Unknown';
logger.info(`Found ${filteredLocations.length} locations in cut: ${cutName}`);
res.json({
locations: filteredLocations,
statistics,
cut: { id: cut.id || cut.Id || cut.ID, name: cutName }
});
} catch (error) {
logger.error('Error getting locations in cut:', error);
res.status(500).json({
error: 'Failed to get locations in cut',
details: error.message
});
}
}
/**
* Export locations within a cut as CSV - admin only
*/
async exportCutLocations(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Get the cut
const cut = await nocodbService.getById(config.nocodb.cutsSheetId, id);
if (!cut) {
return res.status(404).json({ error: 'Cut not found' });
}
// Check if export is enabled for this cut
if (cut.export_enabled === false) {
return res.status(403).json({ error: 'Export is disabled for this cut' });
}
// Get all locations (use paginated to get ALL records, not just first page)
const locationsResponse = await nocodbService.getAllPaginated(config.LOCATIONS_TABLE_ID);
if (!locationsResponse || !locationsResponse.list) {
return res.json({ locations: [] });
}
// Apply filters from query params
const filters = {
support_level: req.query.support_level,
has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined,
sign_size: req.query.sign_size,
has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined,
has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined
};
// Filter locations within cut boundaries
const filteredLocations = spatialUtils.filterLocationsInCut(
locationsResponse.list,
cut,
filters
);
// Generate CSV content
const csvHeaders = [
'ID', 'First Name', 'Last Name', 'Email', 'Phone', 'Address',
'Unit Number', 'Support Level', 'Has Sign', 'Sign Size',
'Latitude', 'Longitude', 'Notes'
];
const csvRows = filteredLocations.map(location => [
location.id || '',
location.first_name || '',
location.last_name || '',
location.email || '',
location.phone || '',
location.address || '',
location.unit_number || '',
location.support_level || '',
location.sign ? 'Yes' : 'No',
location.sign_size || '',
location.latitude || '',
location.longitude || '',
(location.notes || '').replace(/"/g, '""') // Escape quotes in notes
]);
const csvContent = [
csvHeaders.join(','),
...csvRows.map(row => row.map(field => `"${field}"`).join(','))
].join('\n');
const cutName = (cut.name || 'cut').replace(/[^a-zA-Z0-9]/g, '_');
const timestamp = new Date().toISOString().split('T')[0];
const filename = `${cutName}_locations_${timestamp}.csv`;
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(csvContent);
logger.info(`Exported ${filteredLocations.length} locations from cut: ${cut.name}`);
} catch (error) {
logger.error('Error exporting cut locations:', error);
res.status(500).json({
error: 'Failed to export cut locations',
details: error.message
});
}
}
/**
* Update cut settings (visibility, filters, etc.) - admin only
*/
async updateCutSettings(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const {
show_locations,
export_enabled,
assigned_to,
filter_settings,
last_canvassed,
completion_percentage
} = req.body;
const updateData = {
updated_at: new Date().toISOString()
};
// Only include fields that are provided
if (show_locations !== undefined) updateData.show_locations = show_locations;
if (export_enabled !== undefined) updateData.export_enabled = export_enabled;
if (assigned_to !== undefined) updateData.assigned_to = assigned_to;
if (filter_settings !== undefined) updateData.filter_settings = JSON.stringify(filter_settings);
if (last_canvassed !== undefined) updateData.last_canvassed = last_canvassed;
if (completion_percentage !== undefined) updateData.completion_percentage = completion_percentage;
const response = await nocodbService.update(
config.nocodb.cutsSheetId,
id,
updateData
);
logger.info(`Updated cut settings for cut ID: ${id}`);
res.json(response);
} catch (error) {
logger.error('Error updating cut settings:', error);
res.status(500).json({
error: 'Failed to update cut settings',
details: error.message
});
}
}
/**
* Get cut statistics - admin only
*/
async getCutStatistics(req, res) {
try {
const { id } = req.params;
const { isAdmin } = req.user || {};
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
// Get the cut
const cut = await nocodbService.getById(config.nocodb.cutsSheetId, id);
if (!cut) {
return res.status(404).json({ error: 'Cut not found' });
}
// Get all locations (use paginated to get ALL records, not just first page)
const locationsResponse = await nocodbService.getAllPaginated(config.LOCATIONS_TABLE_ID);
if (!locationsResponse || !locationsResponse.list) {
return res.json({
statistics: { total_locations: 0 },
cut: { id: cut.id, name: cut.name }
});
}
// Get locations within cut boundaries (no additional filters)
const locationsInCut = spatialUtils.filterLocationsInCut(
locationsResponse.list,
cut,
{} // No additional filters for statistics
);
// Calculate statistics
const statistics = spatialUtils.calculateCutStatistics(locationsInCut);
// Add cut metadata
const cutStats = {
...statistics,
cut_metadata: {
id: cut.id,
name: cut.name,
category: cut.category,
assigned_to: cut.assigned_to,
completion_percentage: cut.completion_percentage || 0,
last_canvassed: cut.last_canvassed,
created_at: cut.created_at
}
};
logger.info(`Generated statistics for cut: ${cut.name}`);
res.json({ statistics: cutStats });
} catch (error) {
logger.error('Error getting cut statistics:', error);
res.status(500).json({
error: 'Failed to get cut statistics',
details: error.message
});
}
}
}
module.exports = new CutsController();

View File

@@ -0,0 +1,93 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
class DashboardController {
async getStats(req, res) {
try {
// Get all locations using the paginated method
const locationsResponse = await nocodbService.getAllPaginated(config.nocodb.tableId);
const locations = locationsResponse.list || [];
logger.info(`Processing ${locations.length} locations for dashboard stats`);
// Calculate support level distribution
const supportLevels = { '1': 0, '2': 0, '3': 0, '4': 0 };
let signDelivered = 0;
// Track sign sizes for requested signs
const signSizes = { 'Regular': 0, 'Large': 0, 'Unsure': 0 };
locations.forEach(loc => {
// Support levels
if (loc['Support Level']) {
supportLevels[loc['Support Level']]++;
}
// Signs delivered (where Sign checkbox is checked)
if (loc.Sign || loc.sign) {
signDelivered++;
}
// Sign sizes for requested signs (count all with sign size, regardless of delivery)
if (loc['Sign Size']) {
const size = loc['Sign Size'];
if (signSizes.hasOwnProperty(size)) {
signSizes[size]++;
}
}
});
// Calculate overall score (weighted average)
const totalResponses = Object.values(supportLevels).reduce((a, b) => a + b, 0);
const weightedScore = (supportLevels['1'] * 4 + supportLevels['2'] * 3 +
supportLevels['3'] * 2 + supportLevels['4'] * 1) /
(totalResponses || 1);
// Get all users using the paginated method
let users = [];
if (config.nocodb.loginSheetId) {
const usersResponse = await nocodbService.getAllPaginated(config.nocodb.loginSheetId);
users = usersResponse.list || [];
logger.info(`Processing ${users.length} users for dashboard stats`);
} else {
logger.warn('Login sheet ID not configured, skipping user stats');
}
// Get daily entry counts for the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const dailyEntries = {};
locations.forEach(loc => {
const createdAt = new Date(loc.CreatedAt || loc.created_at || loc.createdAt);
if (!isNaN(createdAt.getTime()) && createdAt >= thirtyDaysAgo) {
const dateKey = createdAt.toISOString().split('T')[0];
dailyEntries[dateKey] = (dailyEntries[dateKey] || 0) + 1;
}
});
res.json({
success: true,
data: {
supportLevels,
signDelivered,
signSizes,
totalLocations: locations.length,
overallScore: weightedScore.toFixed(2),
totalUsers: users.length,
dailyEntries
}
});
} catch (error) {
logger.error('Error fetching dashboard stats:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch dashboard statistics'
});
}
}
}
module.exports = new DashboardController();

View File

@@ -0,0 +1,990 @@
const csv = require('csv-parse');
const { Readable } = require('stream');
const nocodbService = require('../services/nocodb');
const { forwardGeocode } = require('../services/geocoding');
const logger = require('../utils/logger');
const config = require('../config');
// In-memory storage for processing results (in production, use Redis or database)
const processingResults = new Map();
class DataConvertController {
constructor() {
// Bind methods to preserve 'this' context
this.processCSV = this.processCSV.bind(this);
this.parseCSV = this.parseCSV.bind(this);
this.saveGeocodedData = this.saveGeocodedData.bind(this);
this.downloadReport = this.downloadReport.bind(this);
this.scanAndGeocode = this.scanAndGeocode.bind(this);
}
// Process CSV upload and geocode addresses with SSE progress updates
async processCSV(req, res) {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'No file uploaded'
});
}
// Store the filename for later use in notes
const originalFilename = req.file.originalname;
const sessionId = Date.now().toString(); // Simple session ID for storing results
// Set up SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable Nginx buffering
});
// Parse CSV
const results = await this.parseCSV(req.file.buffer);
if (!results || results.length === 0) {
res.write(`data: ${JSON.stringify({ type: 'error', message: 'CSV file is empty or invalid' })}\n\n`);
res.end();
return;
}
// Validate required address field
const hasAddressField = results[0].hasOwnProperty('address') ||
results[0].hasOwnProperty('Address') ||
results[0].hasOwnProperty('ADDRESS');
if (!hasAddressField) {
res.write(`data: ${JSON.stringify({ type: 'error', message: 'CSV must contain an "address" column' })}\n\n`);
res.end();
return;
}
// Send initial progress
res.write(`data: ${JSON.stringify({
type: 'start',
total: results.length
})}\n\n`);
res.flush && res.flush();
// Process all addresses
const processedData = [];
const allResults = []; // Store ALL results for report generation
const errors = [];
const total = results.length;
// Process each address with progress updates
for (let i = 0; i < results.length; i++) {
const row = results[i];
// Extract address - with better validation
const addressField = row.address || row.Address || row.ADDRESS ||
row.street_address || row['Street Address'] ||
row.full_address || row['Full Address'];
// Extract unit number if available
const unitField = row.unit || row.Unit || row.UNIT ||
row.unit_number || row['Unit Number'] || row.unit_no;
if (!addressField || addressField.trim() === '') {
logger.warn(`Row ${i + 1}: Empty or missing address field`);
const errorRow = {
...row,
latitude: '',
longitude: '',
'Geo-Location': '',
geocoded_address: '',
geocode_success: false,
geocode_status: 'FAILED',
geocode_error: 'Missing address field',
csv_filename: originalFilename,
row_number: i + 1
};
allResults.push(errorRow);
errors.push({
index: i,
address: 'No address provided',
error: 'Missing address field'
});
// Send progress update
res.write(`data: ${JSON.stringify({
type: 'progress',
current: i + 1,
total: total,
currentAddress: 'No address - skipping',
status: 'failed'
})}\n\n`);
res.flush && res.flush();
continue; // Skip to next row
}
// Construct full address with unit if available
let address = addressField.trim();
if (unitField && unitField.toString().trim()) {
const unit = unitField.toString().trim();
// Add unit prefix if it doesn't already exist
if (!unit.toLowerCase().startsWith('unit') &&
!unit.toLowerCase().startsWith('apt') &&
!unit.toLowerCase().startsWith('#')) {
address = `Unit ${unit}, ${address}`;
} else {
address = `${unit}, ${address}`;
}
} // Send progress update
res.write(`data: ${JSON.stringify({
type: 'progress',
current: i + 1,
total: total,
currentAddress: address,
status: 'processing'
})}\n\n`);
res.flush && res.flush();
try {
logger.info(`Geocoding ${i + 1}/${total}: ${address}`);
// Geocode the address
const geocodeResult = await forwardGeocode(address);
if (geocodeResult && geocodeResult.coordinates) {
// Check if result is malformed
const isMalformed = geocodeResult.validation && geocodeResult.validation.isMalformed;
// Use combined confidence for best overall assessment
const confidence = geocodeResult.combinedConfidence !== undefined ?
geocodeResult.combinedConfidence :
(geocodeResult.validation ? geocodeResult.validation.confidence : 100);
const warnings = geocodeResult.validation ? geocodeResult.validation.warnings : [];
const processedRow = {
...row,
latitude: geocodeResult.coordinates.lat,
longitude: geocodeResult.coordinates.lng,
'Geo-Location': `${geocodeResult.coordinates.lat};${geocodeResult.coordinates.lng}`,
geocoded_address: geocodeResult.formattedAddress || address,
geocode_success: true,
geocode_status: isMalformed ? 'WARNING' : 'SUCCESS',
geocode_error: '',
confidence_score: confidence,
provider_confidence: geocodeResult.providerConfidence || null,
validation_confidence: geocodeResult.validation ? geocodeResult.validation.confidence : null,
warnings: warnings.join('; '),
is_malformed: isMalformed,
provider: geocodeResult.provider || 'Unknown',
csv_filename: originalFilename,
row_number: i + 1
};
processedData.push(processedRow);
allResults.push(processedRow);
// Send success update with status
const successMessage = {
type: 'geocoded',
data: processedRow,
index: i,
status: isMalformed ? 'warning' : 'success',
confidence: confidence,
warnings: warnings
};
const successJson = JSON.stringify(successMessage);
logger.info(`Successfully geocoded: ${address} (Confidence: ${confidence}%)`);
res.write(`data: ${successJson}\n\n`);
res.flush && res.flush();
} else {
throw new Error('Geocoding failed - no coordinates returned');
}
} catch (error) {
logger.error(`Failed to geocode address: ${address}`, error.message);
// Create error row with original data plus error info
const errorRow = {
...row,
latitude: '',
longitude: '',
'Geo-Location': '',
geocoded_address: '',
geocode_success: false,
geocode_status: 'FAILED',
geocode_error: error.message,
confidence_score: 0,
warnings: '',
is_malformed: false,
csv_filename: originalFilename,
row_number: i + 1
};
allResults.push(errorRow);
const errorData = {
index: i,
address: address,
error: error.message
};
errors.push(errorData);
// Send error update
const errorMessage = {
type: 'error',
data: errorData
};
const errorJson = JSON.stringify(errorMessage);
logger.debug(`Sending error update: ${errorJson.length} chars`);
res.write(`data: ${errorJson}\n\n`);
res.flush && res.flush(); // Ensure data is sent immediately
}
// Add delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
}
// Store processing results for report generation
const successful = processedData.filter(r => r.geocode_status === 'SUCCESS').length;
const warnings = processedData.filter(r => r.geocode_status === 'WARNING').length;
const failed = errors.length;
const malformed = processedData.filter(r => r.is_malformed).length;
processingResults.set(sessionId, {
filename: originalFilename,
timestamp: new Date().toISOString(),
allResults: allResults,
summary: {
total: total,
successful: successful,
warnings: warnings,
failed: failed,
malformed: malformed
}
});
// Send completion
const completeMessage = {
type: 'complete',
processed: processedData.length,
successful: successful,
warnings: warnings,
errors: errors.length,
malformed: malformed,
total: total,
sessionId: sessionId // Include session ID for report download
};
const completeJson = JSON.stringify(completeMessage);
logger.info(`Sending completion message: ${completeJson.length} chars`);
res.write(`data: ${completeJson}\n\n`);
res.flush && res.flush(); // Ensure data is sent immediately
res.end();
} catch (error) {
logger.error('CSV processing error:', error);
res.write(`data: ${JSON.stringify({
type: 'fatal_error',
message: 'Failed to process CSV file',
error: error.message
})}\n\n`);
res.end();
}
}
// Parse CSV buffer into array of objects
async parseCSV(buffer) {
return new Promise((resolve, reject) => {
const results = [];
const stream = Readable.from(buffer);
stream
.pipe(csv.parse({
columns: true,
skip_empty_lines: true,
trim: true
}))
.on('data', (data) => results.push(data))
.on('error', reject)
.on('end', () => resolve(results));
});
}
// Enhanced save method that transforms data to match locations table structure
async saveGeocodedData(req, res) {
try {
const { data } = req.body;
if (!data || !Array.isArray(data)) {
return res.status(400).json({
success: false,
error: 'Invalid data format'
});
}
const results = {
success: 0,
failed: 0,
errors: []
};
// Process each location
for (const location of data) {
try {
// Transform to match locations table structure
// Preserve original address, don't overwrite with geocoded address
const originalAddress = location.address || location.Address || location.ADDRESS;
const geocodedAddress = location.geocoded_address;
const locationData = {
'Geo-Location': location['Geo-Location'],
latitude: parseFloat(location.latitude),
longitude: parseFloat(location.longitude),
Address: originalAddress, // Always use the original address from CSV
'Geocode Confidence': location.confidence_score || null, // Add confidence score
'Geocode Provider': location.provider || null, // Add provider name
created_by_user: req.session.userEmail || 'csv_import',
last_updated_by_user: req.session.userEmail || 'csv_import'
};
// Track if geocoded address differs from original
const addressDiffers = geocodedAddress &&
geocodedAddress.toLowerCase() !== originalAddress.toLowerCase();
// Map CSV fields to NocoDB fields
const fieldMapping = {
'first name': 'First Name',
'firstname': 'First Name',
'first_name': 'First Name',
'last name': 'Last Name',
'lastname': 'Last Name',
'last_name': 'Last Name',
'email': 'Email',
'phone': 'Phone',
'unit': 'Unit Number',
'unit number': 'Unit Number',
'unit_number': 'Unit Number',
'support level': 'Support Level',
'support_level': 'Support Level',
'sign': 'Sign',
'sign size': 'Sign Size',
'sign_size': 'Sign Size',
'notes': 'Notes'
};
// Process all fields from CSV
Object.keys(location).forEach(key => {
const lowerKey = key.toLowerCase();
// Skip already processed fields
if (['latitude', 'longitude', 'geo-location', 'geocoded_address', 'geocode_success', 'address', 'csv_filename', 'confidence_score', 'provider_confidence', 'validation_confidence', 'warnings', 'is_malformed', 'provider', 'row_number', 'geocode_status', 'geocode_error'].includes(lowerKey)) {
return;
}
// Check if we have a mapping for this field
if (fieldMapping[lowerKey]) {
const targetField = fieldMapping[lowerKey];
// Special handling for certain fields
if (targetField === 'Sign') {
// Convert to boolean
locationData[targetField] = ['true', 'yes', '1', 'y'].includes(String(location[key]).toLowerCase());
} else if (targetField === 'Support Level') {
// Ensure it's a string number 1-4
const level = parseInt(location[key]);
if (level >= 1 && level <= 4) {
locationData[targetField] = String(level);
}
} else if (targetField === 'Notes') {
// Build notes with existing content, CSV info, and geocoding info
const noteParts = [];
// Add existing notes if present
if (location[key]) {
noteParts.push(location[key]);
}
// Add CSV import info
noteParts.push(`Imported from CSV: ${location.csv_filename || 'unknown'}`);
// Add geocoded address if it differs from original
if (addressDiffers) {
noteParts.push(`Geocoded as: ${geocodedAddress}`);
}
// Add confidence information if available
if (location.confidence_score !== undefined && location.confidence_score !== null) {
noteParts.push(`Geocode confidence: ${location.confidence_score}%`);
}
// Add provider information if available
if (location.provider) {
noteParts.push(`Provider: ${location.provider}`);
}
// Add warnings if present
if (location.warnings && location.warnings.trim()) {
noteParts.push(`Warnings: ${location.warnings}`);
}
locationData[targetField] = noteParts.join(' | ');
} else {
locationData[targetField] = location[key];
}
}
});
// If no notes field was found in CSV, add the CSV import info and geocoding info
if (!locationData['Notes']) {
const noteParts = [`Imported from CSV: ${location.csv_filename || 'unknown'}`];
// Add geocoded address if it differs from original
if (addressDiffers) {
noteParts.push(`Geocoded as: ${geocodedAddress}`);
}
// Add confidence information if available
if (location.confidence_score !== undefined && location.confidence_score !== null) {
noteParts.push(`Geocode confidence: ${location.confidence_score}%`);
}
// Add provider information if available
if (location.provider) {
noteParts.push(`Provider: ${location.provider}`);
}
// Add warnings if present
if (location.warnings && location.warnings.trim()) {
noteParts.push(`Warnings: ${location.warnings}`);
}
locationData['Notes'] = noteParts.join(' | ');
}
// Create location in NocoDB
const result = await nocodbService.create(config.nocodb.tableId, locationData);
results.success++;
logger.debug(`Successfully saved location: ${locationData.Address}`);
} catch (error) {
logger.error('Failed to save location:', error);
logger.error('Location data:', locationData);
results.failed++;
results.errors.push({
address: location.address || location.Address || location.ADDRESS,
error: error.message
});
}
}
res.json({
success: true,
results: results
});
} catch (error) {
logger.error('Save geocoded data error:', error);
res.status(500).json({
success: false,
error: 'Failed to save locations'
});
}
}
// Generate and download processing report
async downloadReport(req, res) {
try {
const { sessionId } = req.params;
const format = req.query.format || 'csv'; // Default to CSV, support 'txt' for backward compatibility
if (!sessionId || !processingResults.has(sessionId)) {
return res.status(404).json({
success: false,
error: 'Processing results not found or expired'
});
}
const results = processingResults.get(sessionId);
const { filename, timestamp, allResults, summary } = results;
let reportContent, contentType, fileExtension;
if (format === 'csv') {
// Generate CSV report
reportContent = this.generateReportCSV(allResults, filename, timestamp, summary);
contentType = 'text/csv';
fileExtension = 'csv';
} else {
// Generate text report (backward compatibility)
reportContent = this.generateComprehensiveReport(allResults, filename, timestamp, summary);
contentType = 'text/plain';
fileExtension = 'txt';
}
// Set headers for download
const reportFilename = `geocoding-report-${sessionId}.${fileExtension}`;
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${reportFilename}"`);
res.setHeader('Cache-Control', 'no-cache');
logger.info(`Generating ${format.toUpperCase()} report for session ${sessionId}: ${allResults.length} records`);
res.send(reportContent);
// Clean up stored results after download (optional)
setTimeout(() => {
processingResults.delete(sessionId);
logger.info(`Cleaned up processing results for session ${sessionId}`);
}, 60000); // Delete after 1 minute
} catch (error) {
logger.error('Download report error:', error);
res.status(500).json({
success: false,
error: 'Failed to generate report'
});
}
}
// Generate comprehensive text report
generateComprehensiveReport(results, originalFilename, timestamp, summary) {
let report = `Geocoding Processing Report\n`;
report += `Generated: ${timestamp}\n`;
report += `Original File: ${originalFilename}\n`;
report += `================================\n\n`;
report += `Summary:\n`;
report += `- Total Addresses: ${summary.total}\n`;
report += `- Successfully Geocoded: ${summary.successful}\n`;
report += `- Warnings (Low Confidence): ${summary.warnings}\n`;
report += `- Failed: ${summary.failed}\n`;
report += `- Potentially Malformed: ${summary.malformed}\n\n`;
// Section for malformed addresses requiring review
const malformedResults = results.filter(r => r.is_malformed);
if (malformedResults.length > 0) {
report += `ADDRESSES REQUIRING REVIEW (Potentially Malformed):\n`;
report += `================================================\n`;
malformedResults.forEach((result, index) => {
const originalAddress = result.address || result.Address || result.ADDRESS || 'N/A';
report += `\n${index + 1}. Original: ${originalAddress}\n`;
report += ` Result: ${result.geocoded_address || 'N/A'}\n`;
report += ` Confidence: ${result.confidence_score || 0}%\n`;
if (result.warnings) {
report += ` Warnings: ${result.warnings}\n`;
}
report += ` Coordinates: ${result.latitude || 'N/A'}, ${result.longitude || 'N/A'}\n`;
report += ` Row: ${result.row_number}\n`;
});
report += `\n`;
}
// Failed addresses section
const failedResults = results.filter(r => r.geocode_status === 'FAILED');
if (failedResults.length > 0) {
report += `FAILED GEOCODING ATTEMPTS:\n`;
report += `========================\n`;
failedResults.forEach((result, index) => {
const originalAddress = result.address || result.Address || result.ADDRESS || 'N/A';
report += `\n${index + 1}. Address: ${originalAddress}\n`;
report += ` Error: ${result.geocode_error}\n`;
report += ` Row: ${result.row_number}\n`;
});
report += `\n`;
}
// Successful geocoding with low confidence
const lowConfidenceResults = results.filter(r =>
r.geocode_status === 'SUCCESS' &&
r.confidence_score &&
r.confidence_score < 75
);
if (lowConfidenceResults.length > 0) {
report += `LOW CONFIDENCE SUCCESSFUL GEOCODING:\n`;
report += `==================================\n`;
lowConfidenceResults.forEach((result, index) => {
const originalAddress = result.address || result.Address || result.ADDRESS || 'N/A';
report += `\n${index + 1}. Original: ${originalAddress}\n`;
report += ` Result: ${result.geocoded_address}\n`;
report += ` Confidence: ${result.confidence_score}%\n`;
if (result.warnings) {
report += ` Warnings: ${result.warnings}\n`;
}
report += ` Row: ${result.row_number}\n`;
});
report += `\n`;
}
// Summary statistics
report += `DETAILED STATISTICS:\n`;
report += `==================\n`;
report += `Success Rate: ${((summary.successful / summary.total) * 100).toFixed(1)}%\n`;
report += `Warning Rate: ${((summary.warnings / summary.total) * 100).toFixed(1)}%\n`;
report += `Failure Rate: ${((summary.failed / summary.total) * 100).toFixed(1)}%\n`;
report += `Malformed Rate: ${((summary.malformed / summary.total) * 100).toFixed(1)}%\n\n`;
// Recommendations
report += `RECOMMENDATIONS:\n`;
report += `===============\n`;
if (summary.malformed > 0) {
report += `- Review ${summary.malformed} addresses marked as potentially malformed\n`;
}
if (summary.failed > 0) {
report += `- Check ${summary.failed} failed addresses for formatting issues\n`;
}
if (summary.warnings > 0) {
report += `- Verify ${summary.warnings} low confidence results manually\n`;
}
report += `- Consider using more specific address formats for better results\n`;
report += `- Ensure addresses include proper directional indicators (NW, SW, etc.)\n`;
return report;
}
// Generate CSV content for the report
generateReportCSV(allResults, originalFilename, timestamp, summary) {
if (!allResults || allResults.length === 0) {
return 'No data available for report generation';
}
// Get all unique field names from the results
const allFields = new Set();
allResults.forEach(row => {
Object.keys(row).forEach(field => allFields.add(field));
});
// Define the header order - put important fields first
const priorityHeaders = [
'geocode_status', 'geocode_error', 'address', 'Address',
'geocoded_address', 'latitude', 'longitude', 'Geo-Location'
];
const otherHeaders = Array.from(allFields).filter(field =>
!priorityHeaders.includes(field) &&
!['geocode_success', 'csv_filename'].includes(field)
).sort();
const headers = [...priorityHeaders.filter(h => allFields.has(h)), ...otherHeaders];
// Generate CSV header with metadata
let csvContent = `# Geocoding Processing Report\n`;
csvContent += `# Original File: ${originalFilename}\n`;
csvContent += `# Processed: ${timestamp}\n`;
csvContent += `# Total Records: ${summary.total}\n`;
csvContent += `# Successful: ${summary.successful}\n`;
csvContent += `# Failed: ${summary.failed}\n`;
csvContent += `# \n`;
// Add CSV headers
csvContent += headers.map(header => this.escapeCSVField(header)).join(',') + '\n';
// Add data rows
allResults.forEach(row => {
const values = headers.map(header => {
const value = row[header];
return this.escapeCSVField(value !== undefined && value !== null ? String(value) : '');
});
csvContent += values.join(',') + '\n';
});
return csvContent;
}
// Escape CSV fields properly
escapeCSVField(field) {
if (field === null || field === undefined) return '';
const stringField = String(field);
// If field contains comma, quote, or newline, wrap in quotes and escape quotes
if (stringField.includes(',') || stringField.includes('"') || stringField.includes('\n') || stringField.includes('\r')) {
return '"' + stringField.replace(/"/g, '""') + '"';
}
return stringField;
}
// Scan NocoDB database for records missing geo-location data and geocode them
async scanAndGeocode(req, res) {
try {
const sessionId = Date.now().toString();
// Set up SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
logger.info(`Starting database scan for missing geo-location data (session: ${sessionId})`);
// Send initial status
res.write(`data: ${JSON.stringify({
type: 'status',
message: 'Scanning database for records missing geo-location data...',
sessionId: sessionId
})}\n\n`);
res.flush && res.flush();
// Fetch all records from NocoDB
let allRecords = [];
let offset = 0;
const limit = 100; // Process in batches
let hasMoreRecords = true;
while (hasMoreRecords) {
try {
const response = await nocodbService.getAll(config.nocodb.tableId, { limit, offset });
if (response && response.list && response.list.length > 0) {
allRecords.push(...response.list);
offset += limit;
// Send progress update
res.write(`data: ${JSON.stringify({
type: 'scanning',
message: `Fetched ${allRecords.length} records from database...`,
count: allRecords.length
})}\n\n`);
res.flush && res.flush();
// Check if we've fetched all records
hasMoreRecords = response.list.length === limit;
} else {
hasMoreRecords = false;
}
} catch (error) {
logger.error('Error fetching records from NocoDB:', error);
res.write(`data: ${JSON.stringify({
type: 'error',
message: `Error fetching records: ${error.message}`
})}\n\n`);
res.end();
return;
}
}
logger.info(`Database scan complete: found ${allRecords.length} total records`);
// Filter records that need geocoding
const recordsNeedingGeocode = allRecords.filter(record => {
// Check if record is missing geo-location data
const hasGeoLocation = record['Geo-Location'] &&
record['Geo-Location'].trim() !== '' &&
record['Geo-Location'] !== 'null';
const hasCoordinates = (record.latitude && record.longitude) ||
(record.Latitude && record.Longitude);
const hasAddress = record.Address || record.address || record.ADDRESS;
return !hasGeoLocation && !hasCoordinates && hasAddress;
});
const totalToGeocode = recordsNeedingGeocode.length;
logger.info(`Found ${totalToGeocode} records needing geocoding`);
// Send summary
res.write(`data: ${JSON.stringify({
type: 'scan_complete',
message: `Scan complete: ${totalToGeocode} records need geocoding`,
total: allRecords.length,
needingGeocode: totalToGeocode
})}\n\n`);
res.flush && res.flush();
if (totalToGeocode === 0) {
res.write(`data: ${JSON.stringify({
type: 'complete',
message: 'No records found that need geocoding. All records already have location data!',
results: { success: 0, failed: 0, skipped: allRecords.length }
})}\n\n`);
res.end();
return;
}
// Process geocoding
const results = {
success: 0,
failed: 0,
errors: [],
sessionId: sessionId
};
const allResults = [];
let processedCount = 0;
for (const record of recordsNeedingGeocode) {
try {
processedCount++;
const address = record.Address || record.address || record.ADDRESS;
// Send progress update
res.write(`data: ${JSON.stringify({
type: 'progress',
current: processedCount,
total: totalToGeocode,
currentAddress: address,
status: 'processing'
})}\n\n`);
res.flush && res.flush();
logger.info(`Geocoding ${processedCount}/${totalToGeocode}: ${address} (Record ID: ${record.id || record.Id || record.ID})`);
// Geocode the address
const geocodeResult = await forwardGeocode(address);
if (geocodeResult && geocodeResult.coordinates) {
// Check if result is malformed
const isMalformed = geocodeResult.validation && geocodeResult.validation.isMalformed;
// Use combined confidence for best overall assessment
const confidence = geocodeResult.combinedConfidence !== undefined ?
geocodeResult.combinedConfidence :
(geocodeResult.validation ? geocodeResult.validation.confidence : 100);
const warnings = geocodeResult.validation ? geocodeResult.validation.warnings : [];
// Update the record in NocoDB
const updateData = {
'Geo-Location': `${geocodeResult.coordinates.lat};${geocodeResult.coordinates.lng}`,
latitude: geocodeResult.coordinates.lat,
longitude: geocodeResult.coordinates.lng,
'Geocode Confidence': confidence,
'Geocode Provider': geocodeResult.provider || 'Unknown',
last_updated_by_user: req.session?.userEmail || 'scan_geocode'
};
// Update the record in NocoDB
await nocodbService.update(config.nocodb.tableId, record.id || record.Id || record.ID, updateData);
const processedRecord = {
id: record.id || record.Id || record.ID,
address: address,
latitude: geocodeResult.coordinates.lat,
longitude: geocodeResult.coordinates.lng,
confidence_score: confidence,
provider: geocodeResult.provider || 'Unknown',
status: isMalformed ? 'WARNING' : 'SUCCESS',
warnings: warnings.join('; ')
};
allResults.push(processedRecord);
results.success++;
// Send success update
const successMessage = {
type: 'geocoded',
data: processedRecord,
index: processedCount - 1,
status: isMalformed ? 'warning' : 'success',
confidence: confidence,
warnings: warnings
};
logger.info(`✓ Successfully geocoded and updated: ${address} (Confidence: ${confidence}%)`);
res.write(`data: ${JSON.stringify(successMessage)}\n\n`);
res.flush && res.flush();
} else {
throw new Error('Geocoding failed - no coordinates returned');
}
} catch (error) {
logger.error(`Failed to geocode record ${processedCount}/${totalToGeocode}:`, error.message);
const errorRecord = {
id: record.id || record.Id || record.ID,
address: record.Address || record.address || record.ADDRESS,
error: error.message,
status: 'ERROR'
};
allResults.push(errorRecord);
results.failed++;
results.errors.push({
address: errorRecord.address,
error: error.message
});
// Send error update
res.write(`data: ${JSON.stringify({
type: 'error',
data: errorRecord,
index: processedCount - 1,
message: `Failed to geocode: ${errorRecord.address}`
})}\n\n`);
res.flush && res.flush();
}
// Rate limiting to be nice to geocoding APIs
if (processedCount < totalToGeocode) {
await new Promise(resolve => setTimeout(resolve, 500)); // 0.5 second delay between requests
}
}
// Calculate summary statistics for report
const successful = allResults.filter(r => r.status === 'SUCCESS').length;
const warnings = allResults.filter(r => r.status === 'WARNING').length;
const failed = allResults.filter(r => r.status === 'ERROR').length;
const malformed = allResults.filter(r => r.warnings && r.warnings.includes('malformed')).length;
const total = successful + warnings + failed;
// Transform scan results to match CSV processing format for report generation
const transformedResults = allResults.map(result => ({
// Original format fields
address: result.address,
Address: result.address,
geocoded_address: result.address, // For scan, this is the same
latitude: result.latitude,
longitude: result.longitude,
'Geo-Location': result.latitude && result.longitude ? `${result.latitude};${result.longitude}` : '',
confidence_score: result.confidence_score,
provider: result.provider,
// Status mapping
geocode_success: result.status !== 'ERROR',
geocode_status: result.status,
geocode_error: result.error || '',
is_malformed: result.warnings && result.warnings.includes('malformed'),
warnings: result.warnings || '',
// Scan-specific fields
record_id: result.id,
source: 'database_scan',
row_number: result.id // Use record ID as row number for scan
}));
// Store results for potential report download
processingResults.set(sessionId, {
filename: 'database_scan',
timestamp: new Date().toISOString(),
allResults: transformedResults,
summary: {
total: total,
successful: successful,
warnings: warnings,
failed: failed,
malformed: malformed
}
});
// Send completion message
logger.info(`Database scan and geocoding completed: ${results.success} successful, ${results.failed} failed`);
res.write(`data: ${JSON.stringify({
type: 'complete',
message: `Scan and geocode completed! Successfully updated ${results.success} records, ${results.failed} failed.`,
results: results,
sessionId: sessionId
})}\n\n`);
res.end();
} catch (error) {
logger.error('Database scan error:', error);
res.write(`data: ${JSON.stringify({
type: 'error',
message: `Database scan failed: ${error.message}`
})}\n\n`);
res.end();
}
}
}
module.exports = new DataConvertController();

View File

@@ -0,0 +1,132 @@
const socrataService = require('../services/socrata');
const logger = require('../utils/logger');
const EDMONTON_PARCEL_ADDRESSES_ID = 'nggt-rwac';
class ExternalDataController {
/**
* Fetches parcel addresses from the City of Edmonton open data portal.
* Uses a simple SoQL query to get points with valid locations.
*/
async getEdmontonParcelAddresses(req, res) {
try {
logger.info('Fetching Edmonton parcel addresses from Socrata API');
// Get query parameters for filtering and pagination
const {
bounds,
zoom = 10,
limit = 2000,
offset = 0,
neighborhood
} = req.query;
// Build dynamic query based on zoom level and bounds
let whereClause = 'location IS NOT NULL';
let selectFields = 'house_number, street_name, sub_address, neighbourhood_name, object_type, location, latitude, longitude';
// If bounds are provided, filter by geographic area
if (bounds) {
try {
const boundsArray = bounds.split(',').map(Number);
if (boundsArray.length === 4) {
const [south, west, north, east] = boundsArray;
whereClause += ` AND latitude BETWEEN ${south} AND ${north} AND longitude BETWEEN ${west} AND ${east}`;
logger.info(`Filtering by bounds: ${bounds}`);
}
} catch (error) {
logger.warn('Invalid bounds parameter:', bounds);
}
}
// Filter by neighborhood if specified
if (neighborhood) {
whereClause += ` AND neighbourhood_name = '${neighborhood.toUpperCase()}'`;
}
// Adjust limit based on zoom level - show fewer points when zoomed out
const dynamicLimit = Math.min(parseInt(zoom) < 12 ? 500 : parseInt(zoom) < 15 ? 1500 : 2000, parseInt(limit));
const params = {
'$select': selectFields,
'$where': whereClause,
'$limit': dynamicLimit,
'$offset': parseInt(offset),
'$order': 'house_number'
};
const data = await socrataService.get(EDMONTON_PARCEL_ADDRESSES_ID, params);
logger.info(`Successfully fetched ${data.length} Edmonton parcel addresses (zoom: ${zoom}, bounds: ${bounds})`);
// Group addresses by location to identify multi-unit buildings
const locationGroups = new Map();
data.filter(item => item.location && item.location.coordinates).forEach(item => {
const locationKey = `${item.latitude}_${item.longitude}`;
const address = `${item.house_number || ''} ${item.street_name || ''}`.trim();
if (!locationGroups.has(locationKey)) {
locationGroups.set(locationKey, {
address: address || 'No address',
location: item.location,
latitude: parseFloat(item.latitude),
longitude: parseFloat(item.longitude),
neighbourhood_name: item.neighbourhood_name || '',
suites: []
});
}
locationGroups.get(locationKey).suites.push({
suite: item.sub_address || item.suite || '',
object_type: item.object_type || 'SUITE',
record_id: item.record_id || '',
house_number: item.house_number || '',
street_name: item.street_name || ''
});
});
// Transform grouped data into GeoJSON FeatureCollection
const validFeatures = Array.from(locationGroups.values()).map(group => ({
type: 'Feature',
properties: {
address: group.address,
neighborhood: group.neighbourhood_name,
suites: group.suites,
suiteCount: group.suites.length,
isMultiUnit: group.suites.length > 3,
lat: group.latitude,
lng: group.longitude
},
geometry: group.location
}));
const geoJson = {
type: 'FeatureCollection',
features: validFeatures,
metadata: {
count: validFeatures.length,
zoom: zoom,
bounds: bounds,
hasMore: validFeatures.length === dynamicLimit // Indicates if there might be more data
}
};
logger.info(`Processed ${validFeatures.length} valid features`);
res.json({
success: true,
data: geoJson
});
} catch (error) {
logger.error('Error fetching Edmonton parcel addresses:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch external map data.'
});
}
}
}
module.exports = new ExternalDataController();

View File

@@ -0,0 +1,252 @@
const listmonkService = require('../services/listmonk');
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
// Get Listmonk sync status
exports.getSyncStatus = async (req, res) => {
try {
const status = listmonkService.getSyncStatus();
// Also check connection if it's enabled
if (status.enabled && !status.connected) {
// Try to reconnect
const reconnected = await listmonkService.checkConnection();
status.connected = reconnected;
}
res.json(status);
} catch (error) {
logger.error('Failed to get Listmonk status', error);
res.status(500).json({
success: false,
error: 'Failed to get sync status'
});
}
};
// Bulk sync all locations to Listmonk
exports.syncAllLocations = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
const locationData = await nocodbService.getLocations();
const locations = locationData?.list || [];
if (!locations || locations.length === 0) {
return res.json({
success: true,
message: 'No locations to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSync(locations, 'location');
res.json({
success: true,
message: `Bulk location sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Bulk location sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync locations to Listmonk'
});
}
};
// Bulk sync all users to Listmonk
exports.syncAllUsers = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
const config = require('../config');
const userData = await nocodbService.getAllPaginated(config.nocodb.loginSheetId);
const users = userData?.list || [];
if (!users || users.length === 0) {
return res.json({
success: true,
message: 'No users to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSync(users, 'user');
res.json({
success: true,
message: `Bulk user sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Bulk user sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync users to Listmonk'
});
}
};
// Sync both locations and users
exports.syncAll = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
let results = {
locations: { total: 0, success: 0, failed: 0, errors: [] },
users: { total: 0, success: 0, failed: 0, errors: [] }
};
// Sync locations
try {
const locationData = await nocodbService.getLocations();
const locations = locationData?.list || [];
if (locations && locations.length > 0) {
results.locations = await listmonkService.bulkSync(locations, 'location');
}
} catch (error) {
logger.error('Failed to sync locations during full sync', error);
results.locations.errors.push({ error: error.message });
}
// Sync users
try {
const userData = await nocodbService.getAllPaginated(config.nocodb.loginSheetId);
const users = userData?.list || [];
if (users && users.length > 0) {
results.users = await listmonkService.bulkSync(users, 'user');
}
} catch (error) {
logger.error('Failed to sync users during full sync', error);
results.users.errors.push({ error: error.message });
}
const totalSuccess = results.locations.success + results.users.success;
const totalFailed = results.locations.failed + results.users.failed;
res.json({
success: true,
message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`,
results
});
} catch (error) {
logger.error('Complete sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to perform complete sync'
});
}
};
// Get Listmonk list statistics
exports.getListStats = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.json({
success: false,
error: 'Listmonk sync is disabled',
stats: null
});
}
const stats = await listmonkService.getListStats();
// Convert stats object to array format for frontend
let statsArray = [];
if (stats && typeof stats === 'object') {
statsArray = Object.entries(stats).map(([key, list]) => ({
id: key,
name: list.name,
subscriberCount: list.subscriber_count || 0,
description: `Email list for ${key}`
}));
}
res.json({
success: true,
stats: statsArray
});
} catch (error) {
logger.error('Failed to get Listmonk list stats', error);
res.status(500).json({
success: false,
error: 'Failed to get list statistics'
});
}
};
// Test Listmonk connection
exports.testConnection = async (req, res) => {
try {
const connected = await listmonkService.checkConnection();
if (connected) {
res.json({
success: true,
message: 'Listmonk connection successful',
connected: true
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Connection failed',
connected: false
});
}
} catch (error) {
logger.error('Failed to test Listmonk connection', error);
res.status(500).json({
success: false,
error: 'Failed to test connection'
});
}
};
// Reinitialize Listmonk lists
exports.reinitializeLists = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
const initialized = await listmonkService.initializeLists();
if (initialized) {
res.json({
success: true,
message: 'Listmonk lists reinitialized successfully'
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Failed to initialize lists'
});
}
} catch (error) {
logger.error('Failed to reinitialize Listmonk lists', error);
res.status(500).json({
success: false,
error: 'Failed to reinitialize lists'
});
}
};

View File

@@ -0,0 +1,414 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const listmonkService = require('../services/listmonk');
const {
syncGeoFields,
validateCoordinates,
checkBounds,
extractId
} = require('../utils/helpers');
class LocationsController {
async getAll(req, res) {
try {
const { limit, offset = 0, where } = req.query;
const params = { offset };
// Only add limit if explicitly provided in query
if (limit !== undefined) {
params.limit = limit;
}
if (where) params.where = where;
logger.info('Fetching locations from NocoDB');
const response = await nocodbService.getLocations(params);
const locations = response.list || [];
// Process and validate locations
const validLocations = locations.filter(loc => {
loc = syncGeoFields(loc);
if (loc.latitude && loc.longitude) {
return true;
}
// Try to parse from geodata column
if (loc.geodata && typeof loc.geodata === 'string') {
const parts = loc.geodata.split(';');
if (parts.length === 2) {
loc.latitude = parseFloat(parts[0]);
loc.longitude = parseFloat(parts[1]);
return !isNaN(loc.latitude) && !isNaN(loc.longitude);
}
}
return false;
});
logger.info(`Retrieved ${validLocations.length} valid locations out of ${locations.length} total`);
// Check if user is temp user and limit data accordingly
if (req.session?.userType === 'temp') {
// For temp users, return limited data but include necessary fields for functionality
const limitedLocations = validLocations.map(loc => {
const locationId = loc.id || loc.Id || loc.ID || loc._id;
return {
// Include ID with all possible variants for compatibility
id: locationId,
Id: locationId,
ID: locationId,
_id: locationId,
'Geo-Location': loc['Geo-Location'],
latitude: loc.latitude,
longitude: loc.longitude,
// Include display fields needed for map functionality
'First Name': loc['First Name'] || '',
'Last Name': loc['Last Name'] || '', // Include last name for display
'Support Level': loc['Support Level'],
Address: loc.Address || '',
'Unit Number': loc['Unit Number'] || '',
Notes: loc.Notes || '',
Sign: loc.Sign,
'Sign Size': loc['Sign Size'] || '',
// Exclude sensitive fields like Email, Phone
};
});
logger.info(`Returning limited data for temp user: ${limitedLocations.length} locations`);
return res.json({
success: true,
count: limitedLocations.length,
total: response.pageInfo?.totalRows || limitedLocations.length,
locations: limitedLocations,
isLimited: true // Flag to indicate limited data
});
}
res.json({
success: true,
count: validLocations.length,
total: response.pageInfo?.totalRows || validLocations.length,
locations: validLocations
});
} catch (error) {
logger.error('Error fetching locations:', error.message);
if (error.response) {
res.status(error.response.status).json({
success: false,
error: 'Failed to fetch data from NocoDB',
details: error.response.data
});
} else if (error.code === 'ECONNABORTED') {
res.status(504).json({
success: false,
error: 'Request timeout'
});
} else {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
}
async getById(req, res) {
try {
const location = await nocodbService.getById(
config.nocodb.tableId,
req.params.id
);
res.json({
success: true,
location
});
} catch (error) {
logger.error(`Error fetching location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to fetch location'
});
}
}
async create(req, res) {
try {
// Add debugging logs
logger.info('Session data:', {
authenticated: req.session.authenticated,
userId: req.session.userId,
userEmail: req.session.userEmail,
isAdmin: req.session.isAdmin
});
let locationData = { ...req.body };
locationData = syncGeoFields(locationData);
const { latitude, longitude, ...additionalData } = locationData;
// Validate coordinates
const validation = validateCoordinates(latitude, longitude);
if (!validation.valid) {
return res.status(400).json({
success: false,
error: validation.error
});
}
// Check bounds if configured
if (config.map.bounds) {
const boundsCheck = checkBounds(validation.latitude, validation.longitude);
if (!boundsCheck.valid) {
return res.status(400).json({
success: false,
error: boundsCheck.error
});
}
}
// Format geodata with string values to preserve precision
const geodata = `${validation.latitude};${validation.longitude}`;
// Prepare data for NocoDB - keep coordinates as strings
const finalData = {
geodata,
'Geo-Location': geodata,
latitude: validation.latitude,
longitude: validation.longitude,
...additionalData,
created_by_user: req.session.userEmail || 'anonymous' // Add fallback
};
logger.info('Final data being sent to NocoDB:', finalData);
logger.info('Creating new location:', {
lat: validation.latitude,
lng: validation.longitude,
user: req.session.userEmail
});
const response = await nocodbService.create(
config.nocodb.tableId,
finalData
);
logger.info('Location created successfully:', extractId(response));
// Real-time sync to Listmonk (async, don't block response)
if (listmonkService.syncEnabled && response.Email) {
setImmediate(async () => {
try {
const syncResult = await listmonkService.syncLocation(response);
if (!syncResult.success) {
logger.warn('Listmonk sync failed for new location', {
locationId: extractId(response),
email: response.Email,
error: syncResult.error
});
} else {
logger.debug('Location synced to Listmonk', {
locationId: extractId(response),
email: response.Email
});
}
} catch (error) {
logger.error('Listmonk sync error for new location', {
locationId: extractId(response),
error: error.message
});
}
});
}
res.status(201).json({
success: true,
location: response
});
} catch (error) {
logger.error('Error creating location:', error.message);
if (error.response) {
res.status(error.response.status).json({
success: false,
error: 'Failed to save location to NocoDB',
details: error.response.data
});
} else {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
}
async update(req, res) {
try {
const locationId = req.params.id;
// Validate ID
if (!locationId || locationId === 'undefined' || locationId === 'null') {
return res.status(400).json({
success: false,
error: 'Invalid location ID'
});
}
let updateData = { ...req.body };
// Remove ID fields to avoid conflicts
delete updateData.ID;
delete updateData.Id;
delete updateData.id;
delete updateData._id;
// Sync geo fields
updateData = syncGeoFields(updateData);
// Add update tracking
updateData.last_updated_by_user = req.session.userEmail; // Changed from last_updated_by
logger.info(`Updating location ${locationId}`, {
user: req.session.userEmail
});
const response = await nocodbService.update(
config.nocodb.tableId,
locationId,
updateData
);
logger.info('Location updated successfully:', locationId);
// Real-time sync to Listmonk (async, don't block response)
if (listmonkService.syncEnabled && response.Email) {
setImmediate(async () => {
try {
const syncResult = await listmonkService.syncLocation(response);
if (!syncResult.success) {
logger.warn('Listmonk sync failed for updated location', {
locationId: locationId,
email: response.Email,
error: syncResult.error
});
} else {
logger.debug('Updated location synced to Listmonk', {
locationId: locationId,
email: response.Email
});
}
} catch (error) {
logger.error('Listmonk sync error for updated location', {
locationId: locationId,
error: error.message
});
}
});
}
res.json({
success: true,
location: response
});
} catch (error) {
logger.error(`Error updating location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to update location',
details: error.response?.data?.message || error.message
});
}
}
async delete(req, res) {
try {
// Check if user is temp and deny delete
if (req.session?.userType === 'temp') {
return res.status(403).json({
success: false,
error: 'Temporary users cannot delete locations'
});
}
const locationId = req.params.id;
// Validate ID
if (!locationId || locationId === 'undefined' || locationId === 'null') {
return res.status(400).json({
success: false,
error: 'Invalid location ID'
});
}
// Get location data before deletion (for Listmonk cleanup)
let locationData = null;
if (listmonkService.syncEnabled) {
try {
const getResponse = await nocodbService.getById(config.nocodb.tableId, locationId);
locationData = getResponse;
} catch (error) {
logger.warn('Could not fetch location data before deletion', error.message);
}
}
await nocodbService.delete(
config.nocodb.tableId,
locationId
);
logger.info(`Location ${locationId} deleted by ${req.session.userEmail}`);
// Remove from Listmonk (async, don't block response)
if (listmonkService.syncEnabled && locationData && locationData.Email) {
setImmediate(async () => {
try {
const syncResult = await listmonkService.removeSubscriber(locationData.Email);
if (!syncResult.success) {
logger.warn('Failed to remove deleted location from Listmonk', {
locationId: locationId,
email: locationData.Email,
error: syncResult.error
});
} else {
logger.debug('Deleted location removed from Listmonk', {
locationId: locationId,
email: locationData.Email
});
}
} catch (error) {
logger.error('Listmonk cleanup error for deleted location', {
locationId: locationId,
error: error.message
});
}
});
}
res.json({
success: true,
message: 'Location deleted successfully'
});
} catch (error) {
logger.error(`Error deleting location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to delete location'
});
}
}
}
module.exports = new LocationsController();

View File

@@ -0,0 +1,60 @@
const nocodbService = require('../services/nocodb');
const { sendPasswordRecovery } = require('../services/email');
const logger = require('../utils/logger');
class PasswordRecoveryController {
async requestPassword(req, res) {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
success: false,
error: 'Email address is required'
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
error: 'Invalid email format'
});
}
logger.info(`Password recovery requested for: ${email}`);
// Find user in database
const user = await nocodbService.getUserByEmail(email);
if (!user) {
// Don't reveal whether the email exists or not for security
logger.warn(`Password recovery attempted for non-existent email: ${email}`);
return res.json({
success: true,
message: 'If an account exists with this email, you will receive your password shortly.'
});
}
// Send password email
await sendPasswordRecovery(user);
logger.info(`Password recovery email sent to: ${email}`);
res.json({
success: true,
message: 'If an account exists with this email, you will receive your password shortly.'
});
} catch (error) {
logger.error('Password recovery error:', error);
res.status(500).json({
success: false,
error: 'Failed to process password recovery request. Please try again later.'
});
}
}
}
module.exports = new PasswordRecoveryController();

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

View File

@@ -0,0 +1,370 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const { validateUrl, extractId, extractWalkSheetConfig } = require('../utils/helpers');
class SettingsController {
// Default settings values
static defaultSettings = {
walk_sheet_title: 'Campaign Walk Sheet',
walk_sheet_subtitle: 'Door-to-Door Canvassing Form',
walk_sheet_footer: 'Thank you for your support!',
qr_code_1_url: '',
qr_code_1_label: '',
qr_code_2_url: '',
qr_code_2_label: '',
qr_code_3_url: '',
qr_code_3_label: ''
};
async getStartLocation(req, res) {
try {
const settings = await nocodbService.getLatestSettings();
if (settings) {
let lat, lng, zoom;
if (settings['Geo-Location']) {
const parts = settings['Geo-Location'].split(';');
if (parts.length === 2) {
lat = parseFloat(parts[0]);
lng = parseFloat(parts[1]);
}
} else if (settings.latitude && settings.longitude) {
lat = parseFloat(settings.latitude);
lng = parseFloat(settings.longitude);
}
zoom = parseInt(settings.zoom) || config.map.defaultZoom;
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
return res.json({
success: true,
location: {
latitude: lat,
longitude: lng,
zoom: zoom
},
source: 'database',
settingsId: extractId(settings),
lastUpdated: settings.created_at
});
}
}
// Return defaults
res.json({
success: true,
location: {
latitude: config.map.defaultLat,
longitude: config.map.defaultLng,
zoom: config.map.defaultZoom
},
source: 'defaults'
});
} catch (error) {
logger.error('Error fetching start location:', error);
// Return defaults on error
res.json({
success: true,
location: {
latitude: config.map.defaultLat,
longitude: config.map.defaultLng,
zoom: config.map.defaultZoom
},
source: 'defaults'
});
}
}
async updateStartLocation(req, res) {
try {
const { latitude, longitude, zoom } = req.body;
// Validate input
if (!latitude || !longitude) {
return res.status(400).json({
success: false,
error: 'Latitude and longitude are required'
});
}
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
const mapZoom = parseInt(zoom) || 11;
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
return res.status(400).json({
success: false,
error: 'Invalid coordinates'
});
}
if (!config.nocodb.settingsSheetId) {
return res.status(500).json({
success: false,
error: 'Settings sheet not configured'
});
}
// Get current settings to preserve other fields
let currentConfig = {};
try {
currentConfig = await nocodbService.getLatestSettings() || {};
// Debug logging to see what we're getting
logger.info('Retrieved current config:', {
id: currentConfig.Id || currentConfig.ID || currentConfig.id,
walk_sheet_title: currentConfig.walk_sheet_title,
walk_sheet_subtitle: currentConfig.walk_sheet_subtitle,
walk_sheet_footer: currentConfig.walk_sheet_footer,
hasFooter: !!currentConfig.walk_sheet_footer,
footerType: typeof currentConfig.walk_sheet_footer,
allKeys: Object.keys(currentConfig)
});
} catch (error) {
logger.warn('Could not retrieve current settings for preservation, using defaults:', error.message);
currentConfig = {};
}
// Create new settings row - use values directly without || operator
const walkSheetConfig = extractWalkSheetConfig(currentConfig, SettingsController.defaultSettings);
const settingData = {
created_at: new Date().toISOString(),
created_by: req.session.userEmail,
// Map location fields (what we're updating)
'Geo-Location': `${lat};${lng}`,
latitude: lat,
longitude: lng,
zoom: mapZoom,
// Preserve walk sheet fields using helper function
...walkSheetConfig
};
logger.info('Creating settings row with data:', {
walk_sheet_footer: settingData.walk_sheet_footer,
footerLength: settingData.walk_sheet_footer?.length
});
const response = await nocodbService.create(
config.nocodb.settingsSheetId,
settingData
);
logger.info('Created new settings row with start location');
res.json({
success: true,
message: 'Start location saved successfully',
location: { latitude: lat, longitude: lng, zoom: mapZoom },
settingsId: extractId(response)
});
} catch (error) {
logger.error('Error updating start location:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to update start location'
});
}
}
async getWalkSheetConfig(req, res) {
try {
if (!config.nocodb.settingsSheetId) {
logger.warn('SETTINGS_SHEET_ID not configured, returning defaults');
return res.json({
success: true,
config: SettingsController.defaultSettings,
source: 'defaults',
message: 'Settings sheet not configured, using defaults'
});
}
const settings = await nocodbService.getLatestSettings();
if (!settings) {
logger.info('No settings found in database, returning defaults');
return res.json({
success: true,
config: SettingsController.defaultSettings,
source: 'defaults',
message: 'No settings found in database'
});
}
const walkSheetConfig = extractWalkSheetConfig(settings, SettingsController.defaultSettings);
logger.info(`Retrieved walk sheet config from database (ID: ${extractId(settings)})`);
res.json({
success: true,
config: walkSheetConfig,
source: 'database',
settingsId: extractId(settings),
lastUpdated: settings.created_at || settings.updated_at
});
} catch (error) {
logger.error('Failed to get walk sheet config:', error);
// Return defaults if there's an error
res.json({
success: true,
config: SettingsController.defaultSettings,
source: 'defaults',
message: 'Error retrieving from database, using defaults',
error: error.message
});
}
}
async updateWalkSheetConfig(req, res) {
try {
if (!config.nocodb.settingsSheetId) {
return res.status(500).json({
success: false,
error: 'Settings sheet not configured'
});
}
const configData = req.body;
logger.info('Received walk sheet config:', JSON.stringify(configData, null, 2));
// Validate input
if (!configData || typeof configData !== 'object') {
return res.status(400).json({
success: false,
error: 'Invalid configuration data'
});
}
// Get current settings to preserve other fields
let currentConfig = {};
try {
currentConfig = await nocodbService.getLatestSettings() || {};
} catch (error) {
logger.warn('Could not retrieve current settings for preservation, using defaults:', error.message);
currentConfig = {};
}
const userEmail = req.session.userEmail;
const timestamp = new Date().toISOString();
// Prepare data for saving
const walkSheetData = {
created_at: timestamp,
created_by: userEmail,
// Preserve map location fields with consistent fallbacks
'Geo-Location': currentConfig['Geo-Location'] || currentConfig.geodata || `${config.map.defaultLat};${config.map.defaultLng}`,
latitude: currentConfig.latitude || config.map.defaultLat,
longitude: currentConfig.longitude || config.map.defaultLng,
zoom: currentConfig.zoom || config.map.defaultZoom,
// Walk sheet fields (what we're updating)
walk_sheet_title: (configData.walk_sheet_title || '').toString().trim(),
walk_sheet_subtitle: (configData.walk_sheet_subtitle || '').toString().trim(),
walk_sheet_footer: (configData.walk_sheet_footer || '').toString().trim(),
'Walk Sheet Title': (configData.walk_sheet_title || '').toString().trim(),
'Walk Sheet Subtitle': (configData.walk_sheet_subtitle || '').toString().trim(),
'Walk Sheet Footer': (configData.walk_sheet_footer || '').toString().trim(),
qr_code_1_url: validateUrl(configData.qr_code_1_url),
qr_code_1_label: (configData.qr_code_1_label || '').toString().trim(),
qr_code_2_url: validateUrl(configData.qr_code_2_url),
qr_code_2_label: (configData.qr_code_2_label || '').toString().trim(),
qr_code_3_url: validateUrl(configData.qr_code_3_url),
qr_code_3_label: (configData.qr_code_3_label || '').toString().trim(),
'QR Code 1 URL': validateUrl(configData.qr_code_1_url),
'QR Code 1 Label': (configData.qr_code_1_label || '').toString().trim(),
'QR Code 2 URL': validateUrl(configData.qr_code_2_url),
'QR Code 2 Label': (configData.qr_code_2_label || '').toString().trim(),
'QR Code 3 URL': validateUrl(configData.qr_code_3_url),
'QR Code 3 Label': (configData.qr_code_3_label || '').toString().trim()
};
const response = await nocodbService.create(
config.nocodb.settingsSheetId,
walkSheetData
);
const newId = extractId(response);
res.json({
success: true,
message: 'Walk sheet configuration saved successfully',
config: walkSheetData,
settingsId: newId,
timestamp: timestamp
});
} catch (error) {
logger.error('Failed to save walk sheet config:', error);
logger.error('Error response:', error.response?.data);
let errorMessage = 'Failed to save walk sheet configuration';
let errorDetails = null;
if (error.response?.data) {
if (error.response.data.message) {
errorMessage = error.response.data.message;
}
if (error.response.data.errors) {
errorDetails = error.response.data.errors;
}
}
res.status(500).json({
success: false,
error: errorMessage,
details: errorDetails,
timestamp: new Date().toISOString()
});
}
}
// Public endpoint for start location (no auth required)
async getPublicStartLocation(req, res) {
try {
const settings = await nocodbService.getLatestSettings();
if (settings) {
let lat, lng, zoom;
if (settings['Geo-Location']) {
const parts = settings['Geo-Location'].split(';');
if (parts.length === 2) {
lat = parseFloat(parts[0]);
lng = parseFloat(parts[1]);
}
} else if (settings.latitude && settings.longitude) {
lat = parseFloat(settings.latitude);
lng = parseFloat(settings.longitude);
}
zoom = parseInt(settings.zoom) || config.map.defaultZoom;
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
logger.info(`Returning location from database: ${lat}, ${lng}, zoom: ${zoom}`);
return res.json({
latitude: lat,
longitude: lng,
zoom: zoom
});
}
}
} catch (error) {
logger.error('Error fetching config start location:', error);
}
// Return defaults
logger.info(`Using default start location: ${config.map.defaultLat}, ${config.map.defaultLng}, zoom: ${config.map.defaultZoom}`);
res.json({
latitude: config.map.defaultLat,
longitude: config.map.defaultLng,
zoom: config.map.defaultZoom
});
}
}
module.exports = new SettingsController();

View File

@@ -0,0 +1,823 @@
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();

View File

@@ -0,0 +1,417 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const { sanitizeUser, extractId } = require('../utils/helpers');
const { sendLoginDetails } = require('../services/email');
const listmonkService = require('../services/listmonk');
class UsersController {
async getAll(req, res) {
try {
// Debug logging
logger.info('UsersController.getAll called');
logger.info('loginSheetId from config:', config.nocodb.loginSheetId);
logger.info('NocoDB config:', {
apiUrl: config.nocodb.apiUrl,
hasToken: !!config.nocodb.apiToken,
projectId: config.nocodb.projectId,
tableId: config.nocodb.tableId,
loginSheetId: config.nocodb.loginSheetId
});
if (!config.nocodb.loginSheetId) {
logger.error('Login sheet not configured in environment');
return res.status(500).json({
success: false,
error: 'Login sheet not configured. Please set NOCODB_LOGIN_SHEET in your environment variables.'
});
}
logger.info('Fetching users from NocoDB...');
// Remove the sort parameter that's causing the error
const response = await nocodbService.getAll(config.nocodb.loginSheetId, {
limit: 100
// Removed: sort: '-created_at'
});
const users = response.list || [];
logger.info(`Retrieved ${users.length} users from database`);
// Remove password field from response for security
const safeUsers = users.map(sanitizeUser);
res.json({
success: true,
users: safeUsers
});
} catch (error) {
logger.error('Error fetching users:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch users: ' + error.message
});
}
}
async create(req, res) {
try {
const { email, password, name, phone, isAdmin, userType, expireDays } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Check if user already exists
const existingUser = await nocodbService.getUserByEmail(email);
if (existingUser) {
return res.status(400).json({
success: false,
error: 'User with this email already exists'
});
}
// Calculate expiration date for temp users
let expiresAt = null;
if (userType === 'temp' && expireDays) {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + expireDays);
expiresAt = expirationDate.toISOString();
}
// Create new user - use the actual column names from your table
const userData = {
Email: email,
email: email,
Password: password,
password: password,
Name: name || '',
name: name || '',
Phone: phone || '',
phone: phone || '',
Admin: isAdmin === true,
admin: isAdmin === true,
'User Type': userType || 'user', // Handle space in field name
UserType: userType || 'user',
userType: userType || 'user',
CreatedAt: new Date().toISOString(),
ExpiresAt: expiresAt,
ExpireDays: userType === 'temp' ? expireDays : null
};
const response = await nocodbService.create(
config.nocodb.loginSheetId,
userData
);
// Real-time sync to Listmonk (async, don't block response)
if (listmonkService.syncEnabled && email) {
setImmediate(async () => {
try {
const userForSync = {
ID: extractId(response),
Email: email,
Name: name,
Phone: phone,
Admin: isAdmin,
'User Type': userType, // Handle space in field name
UserType: userType,
'Created At': new Date().toISOString(),
ExpiresAt: expiresAt
};
const syncResult = await listmonkService.syncUser(userForSync);
if (!syncResult.success) {
logger.warn('Listmonk sync failed for new user', {
userId: extractId(response),
email: email,
error: syncResult.error
});
} else {
logger.debug('User synced to Listmonk', {
userId: extractId(response),
email: email
});
}
} catch (error) {
logger.error('Listmonk sync error for new user', {
email: email,
error: error.message
});
}
});
}
res.status(201).json({
success: true,
message: 'User created successfully',
user: {
id: extractId(response),
email: email,
name: name,
phone: phone,
admin: isAdmin,
userType: userType,
expiresAt: expiresAt
}
});
} catch (error) {
logger.error('Error creating user:', error);
res.status(500).json({
success: false,
error: 'Failed to create user'
});
}
}
async delete(req, res) {
try {
const userId = req.params.id;
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Don't allow admins to delete themselves
if (userId === req.session.userId) {
return res.status(400).json({
success: false,
error: 'Cannot delete your own account'
});
}
// Get user data before deletion (for Listmonk cleanup)
let userData = null;
if (listmonkService.syncEnabled) {
try {
const getResponse = await nocodbService.getById(config.nocodb.loginSheetId, userId);
userData = getResponse;
} catch (error) {
logger.warn('Could not fetch user data before deletion', error.message);
}
}
await nocodbService.delete(
config.nocodb.loginSheetId,
userId
);
// Remove from Listmonk (async, don't block response)
if (listmonkService.syncEnabled && userData && userData.Email) {
setImmediate(async () => {
try {
const syncResult = await listmonkService.syncUser(userData, 'delete');
if (!syncResult.success) {
logger.warn('Failed to remove deleted user from Listmonk', {
userId: userId,
email: userData.Email,
error: syncResult.error
});
} else {
logger.debug('Deleted user removed from Listmonk', {
userId: userId,
email: userData.Email
});
}
} catch (error) {
logger.error('Listmonk cleanup error for deleted user', {
userId: userId,
error: error.message
});
}
});
}
res.json({
success: true,
message: 'User deleted successfully'
});
} catch (error) {
logger.error('Error deleting user:', error);
res.status(500).json({
success: false,
error: 'Failed to delete user'
});
}
}
async sendLoginDetails(req, res) {
try {
const userId = req.params.id;
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Get user data from database
const user = await nocodbService.getById(
config.nocodb.loginSheetId,
userId
);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Send login details email
await sendLoginDetails(user);
res.json({
success: true,
message: 'Login details sent successfully'
});
} catch (error) {
logger.error('Error sending login details:', error);
res.status(500).json({
success: false,
error: 'Failed to send login details'
});
}
}
async emailAllUsers(req, res) {
try {
const { subject, content } = req.body;
if (!subject || !content) {
return res.status(400).json({
success: false,
error: 'Subject and content are required'
});
}
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Get all users
const response = await nocodbService.getAll(config.nocodb.loginSheetId, {
limit: 1000
});
const users = response.list || [];
if (users.length === 0) {
return res.status(400).json({
success: false,
error: 'No users found to email'
});
}
// Import email service
const { sendEmail } = require('../services/email');
const emailTemplates = require('../services/emailTemplates');
const config_app = require('../config');
// Convert rich text content to plain text for the text version
const stripHtmlTags = (html) => {
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
};
// Prepare base template variables
const baseTemplateVariables = {
APP_NAME: 'CMlite Map - User Broadcast',
EMAIL_SUBJECT: subject,
EMAIL_CONTENT: content,
EMAIL_CONTENT_TEXT: stripHtmlTags(content),
SENDER_NAME: req.session.userName || req.session.userEmail || 'Administrator',
TIMESTAMP: new Date().toLocaleString()
};
// Send emails to all users
const emailResults = [];
const failedEmails = [];
for (const user of users) {
try {
const userVariables = {
...baseTemplateVariables,
USER_NAME: user.Name || user.name || user.Email || user.email || 'User',
USER_EMAIL: user.Email || user.email
};
const emailContent = await emailTemplates.render('user-broadcast', userVariables);
await sendEmail({
to: user.Email || user.email,
subject: subject,
text: emailContent.text,
html: emailContent.html
});
emailResults.push({
email: user.Email || user.email,
name: user.Name || user.name || user.Email || user.email,
success: true
});
logger.info(`Sent broadcast email to: ${user.Email || user.email}`);
} catch (emailError) {
logger.error(`Failed to send broadcast email to ${user.Email || user.email}:`, emailError);
failedEmails.push({
email: user.Email || user.email,
name: user.Name || user.name || user.Email || user.email,
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 email to ${successCount} user${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`,
results: {
successful: emailResults,
failed: failedEmails,
total: users.length,
subject: subject
}
});
} catch (error) {
logger.error('Error sending broadcast email:', error);
res.status(500).json({
success: false,
error: 'Failed to send broadcast email'
});
}
}
}
module.exports = new UsersController();