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