diff --git a/map/app/Dockerfile b/map/app/Dockerfile index a4200cd..95c6c6c 100644 --- a/map/app/Dockerfile +++ b/map/app/Dockerfile @@ -26,6 +26,10 @@ COPY server.js ./ COPY public ./public COPY routes ./routes COPY services ./services +COPY config ./config +COPY controllers ./controllers +COPY middleware ./middleware +COPY utils ./utils # Create non-root user RUN addgroup -g 1001 -S nodejs && \ diff --git a/map/app/config/index.js b/map/app/config/index.js new file mode 100644 index 0000000..58a74ef --- /dev/null +++ b/map/app/config/index.js @@ -0,0 +1,98 @@ +const path = require('path'); +require('dotenv').config(); + +// Helper function to parse NocoDB URLs +function parseNocoDBUrl(url) { + if (!url) return { projectId: null, tableId: null }; + + const patterns = [ + /#\/nc\/([^\/]+)\/([^\/\?#]+)/, + /\/nc\/([^\/]+)\/([^\/\?#]+)/, + /project\/([^\/]+)\/table\/([^\/\?#]+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return { + projectId: match[1], + tableId: match[2] + }; + } + } + + return { projectId: null, tableId: null }; +} + +// Auto-parse IDs from URLs +let parsedIds = { projectId: null, tableId: null }; +if (process.env.NOCODB_VIEW_URL && (!process.env.NOCODB_PROJECT_ID || !process.env.NOCODB_TABLE_ID)) { + parsedIds = parseNocoDBUrl(process.env.NOCODB_VIEW_URL); +} + +// Parse login sheet ID +let loginSheetId = null; +if (process.env.NOCODB_LOGIN_SHEET) { + if (process.env.NOCODB_LOGIN_SHEET.startsWith('http')) { + const { tableId } = parseNocoDBUrl(process.env.NOCODB_LOGIN_SHEET); + loginSheetId = tableId; + } else { + loginSheetId = process.env.NOCODB_LOGIN_SHEET; + } +} + +// Parse settings sheet ID +let settingsSheetId = null; +if (process.env.NOCODB_SETTINGS_SHEET) { + if (process.env.NOCODB_SETTINGS_SHEET.startsWith('http')) { + const { tableId } = parseNocoDBUrl(process.env.NOCODB_SETTINGS_SHEET); + settingsSheetId = tableId; + } else { + settingsSheetId = process.env.NOCODB_SETTINGS_SHEET; + } +} + +module.exports = { + // Server config + port: process.env.PORT || 3000, + nodeEnv: process.env.NODE_ENV || 'development', + isProduction: process.env.NODE_ENV === 'production', + + // NocoDB config + nocodb: { + apiUrl: process.env.NOCODB_API_URL, + apiToken: process.env.NOCODB_API_TOKEN, + projectId: process.env.NOCODB_PROJECT_ID || parsedIds.projectId, + tableId: process.env.NOCODB_TABLE_ID || parsedIds.tableId, + loginSheetId, + settingsSheetId, + viewUrl: process.env.NOCODB_VIEW_URL + }, + + // Session config + session: { + secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production', + cookieDomain: process.env.COOKIE_DOMAIN + }, + + // CORS config + cors: { + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || [] + }, + + // Map defaults + map: { + defaultLat: parseFloat(process.env.DEFAULT_LAT) || 53.5461, + defaultLng: parseFloat(process.env.DEFAULT_LNG) || -113.4938, + defaultZoom: parseInt(process.env.DEFAULT_ZOOM) || 11, + bounds: process.env.BOUND_NORTH ? { + north: parseFloat(process.env.BOUND_NORTH), + south: parseFloat(process.env.BOUND_SOUTH), + east: parseFloat(process.env.BOUND_EAST), + west: parseFloat(process.env.BOUND_WEST) + } : null + }, + + // Utility functions + parseNocoDBUrl +}; \ No newline at end of file diff --git a/map/app/controllers/authController.js b/map/app/controllers/authController.js new file mode 100644 index 0000000..b1f02a4 --- /dev/null +++ b/map/app/controllers/authController.js @@ -0,0 +1,138 @@ +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' + }); + } + + // 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.userEmail = email; + req.session.userName = user.Name || user.name || email; + req.session.isAdmin = user.Admin === true || user.Admin === 1 || + user.admin === true || user.admin === 1; + req.session.userId = extractId(user); + + // 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 + } + }); + }); + + } 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) { + res.json({ + authenticated: req.session?.authenticated || false, + user: req.session?.authenticated ? { + email: req.session.userEmail, + name: req.session.userName, + isAdmin: req.session.isAdmin || false + } : null + }); + } +} + +module.exports = new AuthController(); \ No newline at end of file diff --git a/map/app/controllers/locationsController.js b/map/app/controllers/locationsController.js new file mode 100644 index 0000000..1ecfc42 --- /dev/null +++ b/map/app/controllers/locationsController.js @@ -0,0 +1,257 @@ +const nocodbService = require('../services/nocodb'); +const logger = require('../utils/logger'); +const config = require('../config'); +const { + syncGeoFields, + validateCoordinates, + checkBounds, + extractId +} = require('../utils/helpers'); + +class LocationsController { + async getAll(req, res) { + try { + const { limit = 1000, offset = 0, where } = req.query; + + const params = { limit, offset }; + 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`); + + 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 { + 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) { + if (!checkBounds(validation.latitude, validation.longitude, config.map.bounds)) { + return res.status(400).json({ + success: false, + error: 'Location is outside allowed bounds' + }); + } + } + + // Format geodata + const geodata = `${validation.latitude};${validation.longitude}`; + + // Prepare data for NocoDB + const finalData = { + geodata, + 'Geo-Location': geodata, + latitude: validation.latitude, + longitude: validation.longitude, + ...additionalData, + created_at: new Date().toISOString(), + created_by: req.session.userEmail + }; + + logger.info('Creating new location:', { + lat: validation.latitude, + lng: validation.longitude + }); + + const response = await nocodbService.create( + config.nocodb.tableId, + finalData + ); + + logger.info('Location created successfully:', extractId(response)); + + 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); + + updateData.last_updated_at = new Date().toISOString(); + updateData.last_updated_by = req.session.userEmail; + + logger.info(`Updating location ${locationId} by ${req.session.userEmail}`); + + const response = await nocodbService.update( + config.nocodb.tableId, + locationId, + updateData + ); + + 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 { + const locationId = req.params.id; + + // Validate ID + if (!locationId || locationId === 'undefined' || locationId === 'null') { + return res.status(400).json({ + success: false, + error: 'Invalid location ID' + }); + } + + await nocodbService.delete( + config.nocodb.tableId, + locationId + ); + + logger.info(`Location ${locationId} deleted by ${req.session.userEmail}`); + + 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(); \ No newline at end of file diff --git a/map/app/controllers/settingsController.js b/map/app/controllers/settingsController.js new file mode 100644 index 0000000..b5fbd04 --- /dev/null +++ b/map/app/controllers/settingsController.js @@ -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(); \ No newline at end of file diff --git a/map/app/controllers/usersController.js b/map/app/controllers/usersController.js new file mode 100644 index 0000000..045a57b --- /dev/null +++ b/map/app/controllers/usersController.js @@ -0,0 +1,146 @@ +const nocodbService = require('../services/nocodb'); +const logger = require('../utils/logger'); +const config = require('../config'); +const { sanitizeUser, extractId } = require('../utils/helpers'); + +class UsersController { + async getAll(req, res) { + try { + if (!config.nocodb.loginSheetId) { + return res.status(500).json({ + success: false, + error: 'Login sheet not configured' + }); + } + + const response = await nocodbService.getAll(config.nocodb.loginSheetId, { + limit: 100, + sort: '-created_at' + }); + + const users = response.list || []; + + // 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' + }); + } + } + + async create(req, res) { + try { + const { email, password, name, admin } = 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' + }); + } + + // Create new user + const userData = { + Email: email, + email: email, + Password: password, + password: password, + Name: name || '', + name: name || '', + Admin: admin === true, + admin: admin === true, + 'Created At': new Date().toISOString(), + created_at: new Date().toISOString() + }; + + const response = await nocodbService.create( + config.nocodb.loginSheetId, + userData + ); + + res.status(201).json({ + success: true, + message: 'User created successfully', + user: { + id: extractId(response), + email: email, + name: name, + admin: admin + } + }); + + } 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' + }); + } + + await nocodbService.delete( + config.nocodb.loginSheetId, + userId + ); + + 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' + }); + } + } +} + +module.exports = new UsersController(); \ No newline at end of file diff --git a/map/app/middleware/auth.js b/map/app/middleware/auth.js new file mode 100644 index 0000000..fc875cd --- /dev/null +++ b/map/app/middleware/auth.js @@ -0,0 +1,34 @@ +const requireAuth = (req, res, next) => { + if (req.session && req.session.authenticated) { + next(); + } else { + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } else { + res.redirect('/login.html'); + } + } +}; + +const requireAdmin = (req, res, next) => { + if (req.session && req.session.authenticated && req.session.isAdmin) { + next(); + } else { + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } else { + res.redirect('/login.html'); + } + } +}; + +module.exports = { + requireAuth, + requireAdmin +}; \ No newline at end of file diff --git a/map/app/middleware/rateLimiter.js b/map/app/middleware/rateLimiter.js new file mode 100644 index 0000000..519d1ef --- /dev/null +++ b/map/app/middleware/rateLimiter.js @@ -0,0 +1,44 @@ +const rateLimit = require('express-rate-limit'); +const config = require('../config'); + +// Helper to extract real IP with Cloudflare support +const keyGenerator = (req) => { + return req.headers['cf-connecting-ip'] || + req.headers['x-forwarded-for']?.split(',')[0] || + req.ip; +}; + +// General API rate limiter +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, + keyGenerator, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many requests, please try again later.' +}); + +// Strict limiter for write operations +const strictLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + keyGenerator, + message: 'Too many write operations, please try again later.' +}); + +// Auth-specific limiter +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: config.isProduction ? 10 : 50, + keyGenerator, + standardHeaders: true, + legacyHeaders: false, + message: 'Too many login attempts, please try again later.', + skipSuccessfulRequests: true +}); + +module.exports = { + apiLimiter, + strictLimiter, + authLimiter +}; \ No newline at end of file diff --git a/map/app/routes/admin.js b/map/app/routes/admin.js new file mode 100644 index 0000000..3083696 --- /dev/null +++ b/map/app/routes/admin.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const settingsController = require('../controllers/settingsController'); + +// Start location management +router.get('/start-location', settingsController.getStartLocation); +router.post('/start-location', settingsController.updateStartLocation); + +// Walk sheet configuration +router.get('/walk-sheet-config', settingsController.getWalkSheetConfig); +router.post('/walk-sheet-config', settingsController.updateWalkSheetConfig); + +module.exports = router; \ No newline at end of file diff --git a/map/app/routes/auth.js b/map/app/routes/auth.js new file mode 100644 index 0000000..9e6e603 --- /dev/null +++ b/map/app/routes/auth.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); +const { authLimiter } = require('../middleware/rateLimiter'); + +// Login route with rate limiting +router.post('/login', authLimiter, authController.login); + +// Logout route +router.post('/logout', authController.logout); + +// Check authentication status +router.get('/check', authController.check); + +module.exports = router; \ No newline at end of file diff --git a/map/app/routes/debug.js b/map/app/routes/debug.js new file mode 100644 index 0000000..362383b --- /dev/null +++ b/map/app/routes/debug.js @@ -0,0 +1,225 @@ +const express = require('express'); +const router = express.Router(); +const nocodbService = require('../services/nocodb'); +const config = require('../config'); +const logger = require('../utils/logger'); +const { generateQRCode } = require('../services/qrcode'); + +// Debug session endpoint +router.get('/session', (req, res) => { + res.json({ + sessionID: req.sessionID, + session: req.session, + cookies: req.cookies, + authenticated: req.session?.authenticated || false + }); +}); + +// Check table structure +router.get('/table-structure', async (req, res) => { + try { + const response = await nocodbService.getAll(config.nocodb.tableId, { + limit: 1 + }); + + const sample = response.list?.[0] || {}; + + res.json({ + success: true, + fields: Object.keys(sample), + sampleRecord: sample, + idField: sample.ID ? 'ID' : (sample.Id ? 'Id' : (sample.id ? 'id' : 'unknown')) + }); + + } catch (error) { + logger.error('Error checking table structure:', error); + res.status(500).json({ + success: false, + error: 'Failed to check table structure' + }); + } +}); + +// QR code generation test +router.get('/test-qr', async (req, res) => { + try { + const testUrl = req.query.url || 'https://example.com/test'; + const testSize = parseInt(req.query.size) || 200; + + logger.info('Testing local QR code generation...'); + + const qrOptions = { + type: 'png', + width: testSize, + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' + }; + + const buffer = await generateQRCode(testUrl, qrOptions); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length + }); + + res.send(buffer); + + } catch (error) { + logger.error('QR code test failed:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Walk sheet configuration debug +router.get('/walk-sheet-config', async (req, res) => { + try { + const debugInfo = { + settingsSheetId: config.nocodb.settingsSheetId, + settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, + hasSettingsSheet: !!config.nocodb.settingsSheetId, + timestamp: new Date().toISOString() + }; + + if (!config.nocodb.settingsSheetId) { + return res.json({ + success: true, + debug: debugInfo, + message: 'Settings sheet not configured' + }); + } + + // Test connection to settings sheet + const response = await nocodbService.getAll(config.nocodb.settingsSheetId, { + limit: 5, + sort: '-created_at' + }); + + const records = response.list || []; + const sampleRecord = records[0] || {}; + + res.json({ + success: true, + debug: { + ...debugInfo, + connectionTest: 'success', + recordCount: records.length, + availableFields: Object.keys(sampleRecord), + sampleRecord: sampleRecord, + recentRecords: records.slice(0, 3).map(r => ({ + id: r.id || r.Id || r.ID, + created_at: r.created_at, + walk_sheet_title: r.walk_sheet_title, + hasQrCodes: !!(r.qr_code_1_url || r.qr_code_2_url || r.qr_code_3_url) + })) + } + }); + + } catch (error) { + logger.error('Error debugging walk sheet config:', error); + res.json({ + success: false, + debug: { + settingsSheetId: config.nocodb.settingsSheetId, + settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, + hasSettingsSheet: !!config.nocodb.settingsSheetId, + timestamp: new Date().toISOString(), + error: error.message, + errorDetails: error.response?.data + } + }); + } +}); + +// Test walk sheet save +router.post('/test-walk-sheet-save', async (req, res) => { + try { + const testConfig = { + walk_sheet_title: 'Test Walk Sheet', + walk_sheet_subtitle: 'Test Subtitle', + walk_sheet_footer: 'Test Footer', + qr_code_1_url: 'https://example.com/test1', + qr_code_1_label: 'Test QR 1', + qr_code_2_url: 'https://example.com/test2', + qr_code_2_label: 'Test QR 2', + qr_code_3_url: 'https://example.com/test3', + qr_code_3_label: 'Test QR 3' + }; + + logger.info('Testing walk sheet configuration save...'); + + if (!config.nocodb.settingsSheetId) { + return res.json({ + success: false, + test: 'failed', + error: 'Settings sheet not configured', + config: testConfig + }); + } + + const walkSheetData = { + created_at: new Date().toISOString(), + created_by: req.session.userEmail, + ...testConfig + }; + + const response = await nocodbService.create( + config.nocodb.settingsSheetId, + walkSheetData + ); + + res.json({ + success: true, + test: 'passed', + message: 'Test walk sheet configuration saved successfully', + testData: walkSheetData, + saveResponse: response, + settingsId: response.id || response.Id || response.ID + }); + + } catch (error) { + logger.error('Test walk sheet save failed:', error); + res.json({ + success: false, + test: 'failed', + error: error.message, + errorDetails: error.response?.data, + timestamp: new Date().toISOString() + }); + } +}); + +// Raw walk sheet data +router.get('/walk-sheet-raw', async (req, res) => { + try { + if (!config.nocodb.settingsSheetId) { + return res.json({ error: 'No settings sheet ID configured' }); + } + + const response = await nocodbService.getAll(config.nocodb.settingsSheetId, { + sort: '-created_at', + limit: 5 + }); + + return res.json({ + success: true, + tableId: config.nocodb.settingsSheetId, + records: response.list || [], + count: response.list?.length || 0 + }); + } catch (error) { + logger.error('Error fetching raw walk sheet data:', error); + return res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/map/app/routes/index.js b/map/app/routes/index.js new file mode 100644 index 0000000..6ee055b --- /dev/null +++ b/map/app/routes/index.js @@ -0,0 +1,102 @@ +const express = require('express'); +const path = require('path'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +// Import route modules +const authRoutes = require('./auth'); +const locationRoutes = require('./locations'); +const adminRoutes = require('./admin'); +const settingsRoutes = require('./settings'); +const userRoutes = require('./users'); +const qrRoutes = require('./qr'); +const debugRoutes = require('./debug'); +const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes + +module.exports = (app) => { + // Health check (no auth) + app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0' + }); + }); + + // Login page (no auth) + app.get('/login.html', (req, res) => { + res.sendFile(path.join(__dirname, '../public', 'login.html')); + }); + + // Auth routes (no auth required) + app.use('/api/auth', authRoutes); + + // Public config endpoint + app.get('/api/config/start-location', require('../controllers/settingsController').getPublicStartLocation); + + // QR code routes (authenticated) + app.use('/api/qr', requireAuth, qrRoutes); + + // Test QR page (no auth for testing) + app.get('/test-qr', (req, res) => { + res.sendFile(path.join(__dirname, '../public', 'test-qr.html')); + }); + + // Protected routes + app.use('/api/locations', requireAuth, locationRoutes); + app.use('/api/geocode', requireAuth, geocodingRoutes); + app.use('/api/settings', requireAuth, settingsRoutes); + + // Admin routes + app.get('/admin.html', requireAdmin, (req, res) => { + res.sendFile(path.join(__dirname, '../public', 'admin.html')); + }); + app.use('/api/admin', requireAdmin, adminRoutes); + app.use('/api/users', requireAdmin, userRoutes); + + // Debug routes (admin only) + app.use('/api/debug', requireAdmin, debugRoutes); + + // Config check endpoint (authenticated) + app.get('/api/config-check', requireAuth, (req, res) => { + const config = require('../config'); + + const configStatus = { + hasApiUrl: !!config.nocodb.apiUrl, + hasApiToken: !!config.nocodb.apiToken, + hasProjectId: !!config.nocodb.projectId, + hasTableId: !!config.nocodb.tableId, + hasLoginSheet: !!config.nocodb.loginSheetId, + hasSettingsSheet: !!config.nocodb.settingsSheetId, + projectId: config.nocodb.projectId, + tableId: config.nocodb.tableId, + loginSheet: config.nocodb.loginSheetId, + settingsSheet: config.nocodb.settingsSheetId, + nodeEnv: config.nodeEnv + }; + + const isConfigured = configStatus.hasApiUrl && + configStatus.hasApiToken && + configStatus.hasProjectId && + configStatus.hasTableId; + + res.json({ + configured: isConfigured, + ...configStatus + }); + }); + + // Serve static files (protected) + app.use(express.static(path.join(__dirname, '../public'), { + index: false // Don't serve index.html automatically + })); + + // Main app route (protected) + app.get('/', requireAuth, (req, res) => { + res.sendFile(path.join(__dirname, '../public', 'index.html')); + }); + + // Catch all - redirect to login + app.get('*', (req, res) => { + res.redirect('/login.html'); + }); +}; \ No newline at end of file diff --git a/map/app/routes/locations.js b/map/app/routes/locations.js new file mode 100644 index 0000000..795eaa4 --- /dev/null +++ b/map/app/routes/locations.js @@ -0,0 +1,21 @@ +const express = require('express'); +const router = express.Router(); +const locationsController = require('../controllers/locationsController'); +const { strictLimiter } = require('../middleware/rateLimiter'); + +// Get all locations +router.get('/', locationsController.getAll); + +// Get single location +router.get('/:id', locationsController.getById); + +// Create location (with rate limiting) +router.post('/', strictLimiter, locationsController.create); + +// Update location (with rate limiting) +router.put('/:id', strictLimiter, locationsController.update); + +// Delete location (with rate limiting) +router.delete('/:id', strictLimiter, locationsController.delete); + +module.exports = router; \ No newline at end of file diff --git a/map/app/routes/qr.js b/map/app/routes/qr.js new file mode 100644 index 0000000..f8da9f1 --- /dev/null +++ b/map/app/routes/qr.js @@ -0,0 +1,48 @@ +const express = require('express'); +const router = express.Router(); +const logger = require('../utils/logger'); +const { generateQRCode } = require('../services/qrcode'); + +// Generate QR code +router.get('/', async (req, res) => { + try { + const { text, size = 200 } = req.query; + + if (!text) { + return res.status(400).json({ + success: false, + error: 'Text parameter is required' + }); + } + + const qrOptions = { + type: 'png', + width: parseInt(size), + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' + }; + + const buffer = await generateQRCode(text, qrOptions); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length, + 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour + }); + + res.send(buffer); + + } catch (error) { + logger.error('QR code generation error:', error); + res.status(500).json({ + success: false, + error: 'Failed to generate QR code' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/map/app/routes/settings.js b/map/app/routes/settings.js new file mode 100644 index 0000000..160c4f7 --- /dev/null +++ b/map/app/routes/settings.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const settingsController = require('../controllers/settingsController'); + +// Get current settings +router.get('/start-location', settingsController.getStartLocation); +router.get('/walk-sheet', settingsController.getWalkSheetConfig); + +// Update settings (POST routes) +router.post('/start-location', settingsController.updateStartLocation); +router.post('/walk-sheet', settingsController.updateWalkSheetConfig); + +module.exports = router; \ No newline at end of file diff --git a/map/app/routes/users.js b/map/app/routes/users.js new file mode 100644 index 0000000..2e28f50 --- /dev/null +++ b/map/app/routes/users.js @@ -0,0 +1,14 @@ +const express = require('express'); +const router = express.Router(); +const usersController = require('../controllers/usersController'); + +// Get all users +router.get('/', usersController.getAll); + +// Create new user +router.post('/', usersController.create); + +// Delete user +router.delete('/:id', usersController.delete); + +module.exports = router; \ No newline at end of file diff --git a/map/app/server copy.js b/map/app/server copy.js new file mode 100644 index 0000000..0c4e7a1 --- /dev/null +++ b/map/app/server copy.js @@ -0,0 +1,2051 @@ +const express = require('express'); +const axios = require('axios'); +const cors = require('cors'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const winston = require('winston'); +const path = require('path'); +const session = require('express-session'); +const cookieParser = require('cookie-parser'); +require('dotenv').config(); + +// Import geocoding routes +const geocodingRoutes = require('./routes/geocoding'); + +// Import QR code service (only for local generation, no upload) +const { generateQRCode } = require('./services/qrcode'); + +// Parse project and table IDs from view URL +function parseNocoDBUrl(url) { + if (!url) return { projectId: null, tableId: null }; + + // Pattern to match NocoDB URLs + const patterns = [ + /#\/nc\/([^\/]+)\/([^\/\?#]+)/, // matches #/nc/PROJECT_ID/TABLE_ID (dashboard URLs) + /\/nc\/([^\/]+)\/([^\/\?#]+)/, // matches /nc/PROJECT_ID/TABLE_ID + /project\/([^\/]+)\/table\/([^\/\?#]+)/, // alternative pattern + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return { + projectId: match[1], + tableId: match[2] + }; + } + } + + return { projectId: null, tableId: null }; +} + +// Add this helper function near the top of the file after the parseNocoDBUrl function +function syncGeoFields(data) { + // If we have latitude and longitude but no Geo-Location, create it + if (data.latitude && data.longitude && !data['Geo-Location']) { + const lat = parseFloat(data.latitude); + const lng = parseFloat(data.longitude); + if (!isNaN(lat) && !isNaN(lng)) { + data['Geo-Location'] = `${lat};${lng}`; data.geodata = `${lat};${lng}`; } + } + + // If we have Geo-Location but no lat/lng, parse it + else if (data['Geo-Location'] && (!data.latitude || !data.longitude)) { + const geoLocation = data['Geo-Location'].toString(); + + // Try semicolon-separated first + let parts = geoLocation.split(';'); + if (parts.length === 2) { + const lat = parseFloat(parts[0].trim()); + const lng = parseFloat(parts[1].trim()); + if (!isNaN(lat) && !isNaN(lng)) { + data.latitude = lat; + data.longitude = lng; + data.geodata = `${lat};${lng}`; + return data; + } + } + + // Try comma-separated + parts = geoLocation.split(','); + if (parts.length === 2) { + const lat = parseFloat(parts[0].trim()); + const lng = parseFloat(parts[1].trim()); + if (!isNaN(lat) && !isNaN(lng)) { + data.latitude = lat; + data.longitude = lng; + data.geodata = `${lat};${lng}`; + // Normalize Geo-Location to semicolon format for NocoDB GeoData + data['Geo-Location'] = `${lat};${lng}`; + } + } + } + + return data; +} + +// Auto-parse IDs if view URL is provided +if (process.env.NOCODB_VIEW_URL && (!process.env.NOCODB_PROJECT_ID || !process.env.NOCODB_TABLE_ID)) { + const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_VIEW_URL); + + if (projectId && tableId) { + process.env.NOCODB_PROJECT_ID = projectId; + process.env.NOCODB_TABLE_ID = tableId; + console.log(`Auto-parsed from URL - Project ID: ${projectId}, Table ID: ${tableId}`); + } +} + +// Auto-parse login sheet ID if URL is provided +let LOGIN_SHEET_ID = null; +if (process.env.NOCODB_LOGIN_SHEET) { + // Check if it's a URL or just an ID + if (process.env.NOCODB_LOGIN_SHEET.startsWith('http')) { + const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_LOGIN_SHEET); + if (projectId && tableId) { + LOGIN_SHEET_ID = tableId; + console.log(`Auto-parsed login sheet ID from URL: ${LOGIN_SHEET_ID}`); + } else { + console.error('Could not parse login sheet URL'); + } + } else { + // Assume it's already just the ID + LOGIN_SHEET_ID = process.env.NOCODB_LOGIN_SHEET; + console.log(`Using login sheet ID: ${LOGIN_SHEET_ID}`); + } +} + +// Auto-parse settings sheet ID if URL is provided +let SETTINGS_SHEET_ID = null; +if (process.env.NOCODB_SETTINGS_SHEET) { + // Check if it's a URL or just an ID + if (process.env.NOCODB_SETTINGS_SHEET.startsWith('http')) { + const { projectId, tableId } = parseNocoDBUrl(process.env.NOCODB_SETTINGS_SHEET); + if (projectId && tableId) { + SETTINGS_SHEET_ID = tableId; + console.log(`Auto-parsed settings sheet ID from URL: ${SETTINGS_SHEET_ID}`); + } else { + console.error('Could not parse settings sheet URL'); + } + } else { + // Assume it's already just the ID + SETTINGS_SHEET_ID = process.env.NOCODB_SETTINGS_SHEET; + console.log(`Using settings sheet ID: ${SETTINGS_SHEET_ID}`); + } +} + +// Configure logger +const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.Console({ + format: winston.format.simple() + }) + ] +}); + +// Initialize Express app +const app = express(); +const PORT = process.env.PORT || 3000; + +// Session configuration +app.use(cookieParser()); + +// Determine if we should use secure cookies based on environment and request +const isProduction = process.env.NODE_ENV === 'production'; + +// Cookie configuration function +const getCookieConfig = (req) => { + const host = req?.get('host') || ''; + const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1') || host.match(/^\d+\.\d+\.\d+\.\d+/); + + const config = { + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + sameSite: 'lax', + secure: false, // Default to false + domain: undefined // Default to no domain restriction + }; + + // Only set domain and secure for production non-localhost access + if (isProduction && !isLocalhost && process.env.COOKIE_DOMAIN) { + // Check if the request is coming from a subdomain of COOKIE_DOMAIN + const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, ''); if (host.includes(cookieDomain)) { + config.domain = process.env.COOKIE_DOMAIN; + config.secure = true; // Enable secure cookies for production + } + } + + return config; +}; + +app.use(session({ + secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production', + resave: false, + saveUninitialized: false, + cookie: getCookieConfig(), + name: 'nocodb-map-session', + genid: (req) => { + // Use a custom session ID generator to avoid conflicts + return require('crypto').randomBytes(16).toString('hex'); + } +})); + +// Security middleware +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"], + imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://unpkg.com"], + connectSrc: ["'self'"] + } + } +})); + +// CORS configuration +app.use(cors({ + origin: function(origin, callback) { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || []; + if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + +// Trust proxy for Cloudflare +app.set('trust proxy', true); + +// Rate limiting with Cloudflare support +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + keyGenerator: (req) => { + // Use CF-Connecting-IP header if available (Cloudflare) + return req.headers['cf-connecting-ip'] || + req.headers['x-forwarded-for']?.split(',')[0] || + req.ip; + }, + standardHeaders: true, + legacyHeaders: false, +}); + +const strictLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + keyGenerator: (req) => { + return req.headers['cf-connecting-ip'] || + req.headers['x-forwarded-for']?.split(',')[0] || + req.ip; + } +}); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 10 : 50, // Increase limit slightly + message: 'Too many login attempts, please try again later.', + keyGenerator: (req) => { + return req.headers['cf-connecting-ip'] || + req.headers['x-forwarded-for']?.split(',')[0] || + req.ip; + }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Middleware +app.use(express.json({ limit: '10mb' })); + +// Authentication middleware +const requireAuth = (req, res, next) => { + if (req.session && req.session.authenticated) { + next(); + } else { + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(401).json({ success: false, error: 'Authentication required' }); + } else { + res.redirect('/login.html'); + } + } +}; + +// Admin middleware +const requireAdmin = (req, res, next) => { + if (req.session && req.session.authenticated && req.session.isAdmin) { + next(); + } else { + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(403).json({ success: false, error: 'Admin access required' }); + } else { + res.redirect('/login.html'); + } + } +}; + +// Serve login page without authentication +app.get('/login.html', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'login.html')); +}); + +// Auth routes (no authentication required) +app.post('/api/auth/login', authLimiter, async (req, res) => { + try { + // Log request details for debugging + logger.info('Login attempt:', { + email: req.body.email, + ip: req.ip, + cfIp: req.headers['cf-connecting-ip'], + forwardedFor: req.headers['x-forwarded-for'], + userAgent: req.headers['user-agent'] + }); + + const { email, password } = req.body; + + 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' + }); + } + + // Check if login sheet is configured + if (!LOGIN_SHEET_ID) { + logger.error('NOCODB_LOGIN_SHEET not configured or could not be parsed'); + return res.status(500).json({ + success: false, + error: 'Authentication system not properly configured' + }); + } + + // Fetch user from NocoDB + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}`; + + logger.info(`Checking authentication for email: ${email}`); + logger.debug(`Using login sheet API: ${url}`); + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + where: `(Email,eq,${email})`, + limit: 1 + } + }); + + const users = response.data.list || []; + + if (users.length === 0) { + logger.warn(`No user found with email: ${email}`); + return res.status(401).json({ + success: false, + error: 'Invalid email or password' + }); + } + + const user = users[0]; + + // Check password (plain text comparison for now) + 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' + }); + } + + // Update last login time + try { + const updateUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}/${user.Id || user.id || user.ID}`; + await axios.patch(updateUrl, { + 'Last Login': new Date().toISOString(), + last_login: new Date().toISOString() + }, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + }); + } catch (updateError) { + logger.warn('Failed to update last login time:', updateError.message); + // Don't fail the login if we can't update last login time + } + + // Set session including admin status + req.session.authenticated = true; + req.session.userEmail = email; + req.session.userName = user.Name || user.name || email; + req.session.isAdmin = user.Admin === true || user.Admin === 1 || user.admin === true || user.admin === 1; + req.session.userId = user.Id || user.id || user.ID; + + // Force session save before sending response + 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 + } + }); + }); + + } catch (error) { + logger.error('Login error:', error.message); + res.status(500).json({ + success: false, + error: 'Authentication service error. Please try again later.' + }); + } +}); + +app.get('/api/auth/check', (req, res) => { + res.json({ + authenticated: req.session?.authenticated || false, + user: req.session?.authenticated ? { + email: req.session.userEmail, + name: req.session.userName, + isAdmin: req.session.isAdmin || false + } : null + }); +}); + +app.post('/api/auth/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' + }); + }); +}); + +// Admin routes +// Serve admin page (protected) +app.get('/admin.html', requireAdmin, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'admin.html')); +}); + +// Add admin API endpoint to update start location +app.post('/api/admin/start-location', requireAdmin, async (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 (!SETTINGS_SHEET_ID) { + return res.status(500).json({ + success: false, + error: 'Settings sheet not configured' + }); + } + + // Get the most recent settings to preserve ALL fields + let currentConfig = {}; + try { + const response = await axios.get( + `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, + { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + sort: '-created_at', + limit: 1 + } + } + ); + + if (response.data?.list && response.data.list.length > 0) { + currentConfig = response.data.list[0]; + logger.info('Loaded existing settings for preservation'); + } + } catch (e) { + logger.warn('Could not load existing settings, using defaults:', e.message); + } + + // Create new settings row with updated location but preserve everything else + 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 all walk sheet fields + walk_sheet_title: currentConfig.walk_sheet_title || 'Campaign Walk Sheet', + walk_sheet_subtitle: currentConfig.walk_sheet_subtitle || 'Door-to-Door Canvassing Form', + walk_sheet_footer: currentConfig.walk_sheet_footer || 'Thank you for your support!', + qr_code_1_url: currentConfig.qr_code_1_url || '', + qr_code_1_label: currentConfig.qr_code_1_label || '', + qr_code_2_url: currentConfig.qr_code_2_url || '', + qr_code_2_label: currentConfig.qr_code_2_label || '', + qr_code_3_url: currentConfig.qr_code_3_url || '', + qr_code_3_label: currentConfig.qr_code_3_label || '' + }; + + const createUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + const createResponse = await axios.post(createUrl, settingData, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + }); + + 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: createResponse.data.id || createResponse.data.Id || createResponse.data.ID + }); + + } catch (error) { + logger.error('Error updating start location:', error); + res.status(500).json({ + success: false, + error: error.message || 'Failed to update start location' + }); + } +}); + +// Get current start location (fetch most recent) +app.get('/api/admin/start-location', requireAdmin, async (req, res) => { + try { + // First try to get from database + if (SETTINGS_SHEET_ID) { + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN + }, + params: { + sort: '-created_at', // Get most recent + limit: 1 + } + }); + + const settings = response.data.list || []; + + if (settings.length > 0) { + const setting = settings[0]; + + // Try to extract coordinates + let lat, lng, zoom; + + if (setting['Geo-Location']) { + const parts = setting['Geo-Location'].split(';'); + if (parts.length === 2) { + lat = parseFloat(parts[0]); + lng = parseFloat(parts[1]); + } + } else if (setting.latitude && setting.longitude) { + lat = parseFloat(setting.latitude); + lng = parseFloat(setting.longitude); + } + + zoom = parseInt(setting.zoom) || 11; + + if (lat && lng && !isNaN(lat) && !isNaN(lng)) { + return res.json({ + success: true, + location: { + latitude: lat, + longitude: lng, + zoom: zoom + }, + source: 'database', + settingsId: setting.id || setting.Id || setting.ID, + lastUpdated: setting.created_at + }); + } + } + } + + // Fallback to environment variables + res.json({ + success: true, + location: { + latitude: 53.5461, + longitude: -113.4938, + zoom: 11 + }, + source: 'defaults' + }); + + } catch (error) { + logger.error('Error fetching start location:', error); + + // Return defaults on error + res.json({ + success: true, + location: { + latitude: 53.5461, + longitude: -113.4938, + zoom: 11 + }, + source: 'defaults' + }); + } +}); + +// Update the public config endpoint similarly +app.get('/api/config/start-location', async (req, res) => { + try { + // Try to get from database first + if (SETTINGS_SHEET_ID) { + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; + + logger.info(`Fetching start location from settings sheet: ${SETTINGS_SHEET_ID}`); + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN + }, + params: { + sort: '-created_at', // Get most recent + limit: 1 + } + }); + + const settings = response.data.list || []; + + if (settings.length > 0) { + const setting = settings[0]; + logger.info('Found settings row:', { + id: setting.id || setting.Id || setting.ID, + hasGeoLocation: !!setting['Geo-Location'], + hasLatLng: !!(setting.latitude && setting.longitude) + }); + + // Try to extract coordinates + let lat, lng, zoom; + + if (setting['Geo-Location']) { + const parts = setting['Geo-Location'].split(';'); + if (parts.length === 2) { + lat = parseFloat(parts[0]); + lng = parseFloat(parts[1]); + } + } else if (setting.latitude && setting.longitude) { + lat = parseFloat(setting.latitude); + lng = parseFloat(setting.longitude); + } + + zoom = parseInt(setting.zoom) || 11; + + 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 + }); + } + } else { + logger.info('No settings found in database'); + } + } else { + logger.info('Settings sheet not configured, using defaults'); + } + } catch (error) { + logger.error('Error fetching config start location:', error); + } + + // Return defaults + const defaultLat = parseFloat(process.env.DEFAULT_LAT) || 53.5461; + const defaultLng = parseFloat(process.env.DEFAULT_LNG) || -113.4938; + const defaultZoom = parseInt(process.env.DEFAULT_ZOOM) || 11; + + logger.info(`Using default start location: ${defaultLat}, ${defaultLng}, zoom: ${defaultZoom}`); + + res.json({ + latitude: defaultLat, + longitude: defaultLng, + zoom: defaultZoom + }); +}); + +// Get walk sheet configuration (load most recent) +app.get('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { + try { + // Default configuration + const defaultConfig = { + 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: '' + }; + + if (!SETTINGS_SHEET_ID) { + logger.warn('SETTINGS_SHEET_ID not configured, returning defaults'); + return res.json({ + success: true, + config: defaultConfig, + source: 'defaults', + message: 'Settings sheet not configured, using defaults' + }); + } + + // Get ALL settings rows and find the most recent one with walk sheet data + const response = await axios.get( + `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, + { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + sort: '-created_at', // Sort by created_at descending + limit: 20 // Get more records to find one with walk sheet data + } + } + ); + + logger.debug('GET Settings response structure:', JSON.stringify(response.data, null, 2)); + + if (!response.data?.list || response.data.list.length === 0) { + logger.info('No settings found in database, returning defaults'); + return res.json({ + success: true, + config: defaultConfig, + source: 'defaults', + message: 'No settings found in database' + }); + } + + // Find the first row that has walk sheet configuration (not just location data) + const settingsRow = response.data.list.find(row => + row.walk_sheet_title || + row.walk_sheet_subtitle || + row.walk_sheet_footer || + row.qr_code_1_url || + row.qr_code_2_url || + row.qr_code_3_url + ) || response.data.list[0]; // Fallback to most recent if none have walk sheet data + + const walkSheetConfig = { + walk_sheet_title: settingsRow.walk_sheet_title || settingsRow['Walk Sheet Title'] || defaultConfig.walk_sheet_title, + walk_sheet_subtitle: settingsRow.walk_sheet_subtitle || settingsRow['Walk Sheet Subtitle'] || defaultConfig.walk_sheet_subtitle, + walk_sheet_footer: settingsRow.walk_sheet_footer || settingsRow['Walk Sheet Footer'] || defaultConfig.walk_sheet_footer, + qr_code_1_url: settingsRow.qr_code_1_url || settingsRow['QR Code 1 URL'] || defaultConfig.qr_code_1_url, + qr_code_1_label: settingsRow.qr_code_1_label || settingsRow['QR Code 1 Label'] || defaultConfig.qr_code_1_label, + qr_code_2_url: settingsRow.qr_code_2_url || settingsRow['QR Code 2 URL'] || defaultConfig.qr_code_2_url, + qr_code_2_label: settingsRow.qr_code_2_label || settingsRow['QR Code 2 Label'] || defaultConfig.qr_code_2_label, + qr_code_3_url: settingsRow.qr_code_3_url || settingsRow['QR Code 3 URL'] || defaultConfig.qr_code_3_url, + qr_code_3_label: settingsRow.qr_code_3_label || settingsRow['QR Code 3 Label'] || defaultConfig.qr_code_3_label + }; + + logger.info(`Retrieved walk sheet config from database (ID: ${settingsRow.Id || settingsRow.id})`); + res.json({ + success: true, + config: walkSheetConfig, + source: 'database', + settingsId: settingsRow.id || settingsRow.Id || settingsRow.ID, + lastUpdated: settingsRow.created_at || settingsRow.updated_at + }); + + } catch (error) { + logger.error('Failed to get walk sheet config:', error); + logger.error('Error details:', error.response?.data || error.message); + + // Return defaults if there's an error + res.json({ + success: true, + config: { + 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: '' + }, + source: 'defaults', + message: 'Error retrieving from database, using defaults', + error: error.message + }); + } +}); + +// Save walk sheet configuration (always create new row) +app.post('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { + try { + if (!SETTINGS_SHEET_ID) { + return res.status(500).json({ + success: false, + error: 'Settings sheet not configured' + }); + } + + logger.info('Using SETTINGS_SHEET_ID:', SETTINGS_SHEET_ID); + + const config = req.body; + logger.info('Received walk sheet config:', JSON.stringify(config, null, 2)); + + // Validate input + if (!config || typeof config !== 'object') { + return res.status(400).json({ + success: false, + error: 'Invalid configuration data' + }); + } + + // Get the most recent settings to preserve ALL fields + let currentConfig = {}; + try { + const response = await axios.get( + `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, + { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + params: { + sort: '-created_at', + limit: 1 + } + } + ); + + if (response.data?.list && response.data.list.length > 0) { + currentConfig = response.data.list[0]; + logger.info('Loaded existing settings for preservation'); + } + } catch (e) { + logger.warn('Could not load existing settings, using defaults:', e.message); + } + + const userEmail = req.session.userEmail; + const timestamp = new Date().toISOString(); + + // Prepare data for saving - include ALL fields + const walkSheetData = { + created_at: timestamp, + created_by: userEmail, + // Preserve map location fields from last saved config + 'Geo-Location': currentConfig['Geo-Location'] || currentConfig.geodata || '53.5461;-113.4938', + latitude: currentConfig.latitude || 53.5461, + longitude: currentConfig.longitude || -113.4938, + zoom: currentConfig.zoom || 11, + // Walk sheet fields (what we're updating) + walk_sheet_title: (config.walk_sheet_title || '').toString().trim(), + walk_sheet_subtitle: (config.walk_sheet_subtitle || '').toString().trim(), + walk_sheet_footer: (config.walk_sheet_footer || '').toString().trim(), + 'Walk Sheet Title': (config.walk_sheet_title || '').toString().trim(), + 'Walk Sheet Subtitle': (config.walk_sheet_subtitle || '').toString().trim(), + 'Walk Sheet Footer': (config.walk_sheet_footer || '').toString().trim(), + qr_code_1_url: validateUrl(config.qr_code_1_url), + qr_code_1_label: (config.qr_code_1_label || '').toString().trim(), + qr_code_2_url: validateUrl(config.qr_code_2_url), + qr_code_2_label: (config.qr_code_2_label || '').toString().trim(), + qr_code_3_url: validateUrl(config.qr_code_3_url), + qr_code_3_label: (config.qr_code_3_label || '').toString().trim(), + 'QR Code 1 URL': validateUrl(config.qr_code_1_url), + 'QR Code 1 Label': (config.qr_code_1_label || '').toString().trim(), + 'QR Code 2 URL': validateUrl(config.qr_code_2_url), + 'QR Code 2 Label': (config.qr_code_2_label || '').toString().trim(), + 'QR Code 3 URL': validateUrl(config.qr_code_3_url), + 'QR Code 3 Label': (config.qr_code_3_label || '').toString().trim() + }; + + logger.info('Prepared walk sheet data for saving:', JSON.stringify(walkSheetData, null, 2)); + + // Create new settings row + const response = await axios.post( + `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, + walkSheetData, + { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + } + ); + + logger.info('NocoDB create response:', JSON.stringify(response.data, null, 2)); + + const newId = response.data.id || response.data.Id || response.data.ID; + + 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); + logger.error('Request URL:', error.config?.url); + + // Provide more detailed error information + 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() + }); + } +}); + +// Helper function to validate URLs +function validateUrl(url) { + if (!url || typeof url !== 'string') { + return ''; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ''; + } + + // Basic URL validation + try { + new URL(trimmed); + return trimmed; + } catch (e) { + // If not a valid URL, check if it's a relative path or missing protocol + if (trimmed.startsWith('/') || !trimmed.includes('://')) { + // For relative paths or missing protocol, return as-is + return trimmed; + } + logger.warn('Invalid URL provided:', trimmed); + return ''; + } +} + +// Debug session endpoint +app.get('/api/debug/session', (req, res) => { + res.json({ + sessionID: req.sessionID, + session: req.session, + cookies: req.cookies, + authenticated: req.session?.authenticated || false + }); +}); + +// Serve static files with authentication for main app +app.use(express.static(path.join(__dirname, 'public'), { + index: false // Don't serve index.html automatically +})); + +// Protect main app routes +app.get('/', requireAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Add geocoding routes (protected) +app.use('/api/geocode', requireAuth, geocodingRoutes); + +// Apply rate limiting to API routes +app.use('/api/', limiter); + +// Health check endpoint (no auth required) +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0' + }); +}); + +// Configuration validation endpoint (protected) +app.get('/api/config-check', requireAuth, (req, res) => { + const config = { + hasApiUrl: !!process.env.NOCODB_API_URL, + hasApiToken: !!process.env.NOCODB_API_TOKEN, + hasProjectId: !!process.env.NOCODB_PROJECT_ID, + hasTableId: !!process.env.NOCODB_TABLE_ID, + hasLoginSheet: !!LOGIN_SHEET_ID, + hasSettingsSheet: !!SETTINGS_SHEET_ID, + projectId: process.env.NOCODB_PROJECT_ID, + tableId: process.env.NOCODB_TABLE_ID, + loginSheet: LOGIN_SHEET_ID, + loginSheetConfigured: process.env.NOCODB_LOGIN_SHEET, + settingsSheet: SETTINGS_SHEET_ID, + settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET, + nodeEnv: process.env.NODE_ENV + }; + + const isConfigured = config.hasApiUrl && config.hasApiToken && config.hasProjectId && config.hasTableId; + + res.json({ + configured: isConfigured, + ...config + }); +}); + +// All other API routes require authentication +app.use('/api/*', requireAuth); + +// Get all locations from NocoDB +app.get('/api/locations', async (req, res) => { + try { + const { limit = 1000, offset = 0, where } = req.query; + + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; + + const params = new URLSearchParams({ + limit, + offset + }); + + if (where) { + params.append('where', where); + } + + logger.info(`Fetching locations from NocoDB: ${url}`); + + const response = await axios.get(`${url}?${params}`, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + }, + timeout: 10000 // 10 second timeout + }); + + // Process locations to ensure they have required fields + const locations = response.data.list || []; + + // Log the structure of the first location to debug ID field name + if (locations.length > 0) { + const sampleLocation = locations[0]; + logger.info('Sample location structure:', { + keys: Object.keys(sampleLocation), + idFields: { + 'Id': sampleLocation.Id, + 'id': sampleLocation.id, + 'ID': sampleLocation.ID, + '_id': sampleLocation._id + } + }); + } + + const validLocations = locations.filter(loc => { + // Apply geo field synchronization to each location + loc = syncGeoFields(loc); + + // Check if location has valid coordinates + if (loc.latitude && loc.longitude) { + return true; + } + + // Try to parse from geodata column (semicolon-separated) + 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`); + + res.json({ + success: true, + count: validLocations.length, + total: response.data.pageInfo?.totalRows || validLocations.length, + locations: validLocations + }); + + } catch (error) { + logger.error('Error fetching locations:', error.message); + + if (error.response) { + // NocoDB API error + res.status(error.response.status).json({ + success: false, + error: 'Failed to fetch data from NocoDB', + details: error.response.data + }); + } else if (error.code === 'ECONNABORTED') { + // Timeout + res.status(504).json({ + success: false, + error: 'Request timeout' + }); + } else { + // Other errors + res.status(500).json({ + success: false, + error: 'Internal server error' + }); + } + } +}); + +// Get single location by ID +app.get('/api/locations/:id', async (req, res) => { + try { + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN + } + }); + + res.json({ + success: true, + location: response.data + }); + + } 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' + }); + } +}); + +// Create new location +app.post('/api/locations', strictLimiter, async (req, res) => { + try { + let locationData = { ...req.body }; + + // Sync geo fields before validation + locationData = syncGeoFields(locationData); + + const { latitude, longitude, ...additionalData } = locationData; + + // Validate coordinates + if (!latitude || !longitude) { + return res.status(400).json({ + success: false, + error: 'Latitude and longitude are required' + }); + } + + const lat = parseFloat(latitude); + const lng = parseFloat(longitude); + + if (isNaN(lat) || isNaN(lng)) { + return res.status(400).json({ + success: false, + error: 'Invalid coordinate values' + }); + } + + if (lat < -90 || lat > 90) { + return res.status(400).json({ + success: false, + error: 'Latitude must be between -90 and 90' + }); + } + + if (lng < -180 || lng > 180) { + return res.status(400).json({ + success: false, + error: 'Longitude must be between -180 and 180' + }); + } + + // Check bounds if configured + if (process.env.BOUND_NORTH) { + const bounds = { + north: parseFloat(process.env.BOUND_NORTH), + south: parseFloat(process.env.BOUND_SOUTH), + east: parseFloat(process.env.BOUND_EAST), + west: parseFloat(process.env.BOUND_WEST) + }; + + if (lat > bounds.north || lat < bounds.south || + lng > bounds.east || lng < bounds.west) { + return res.status(400).json({ + success: false, + error: 'Location is outside allowed bounds' + }); + } + } + + // Format geodata in both formats for compatibility + const geodata = `${lat};${lng}`; + const geoLocation = `${lat};${lng}`; // Use semicolon format for NocoDB GeoData column + + // Prepare data for NocoDB + const finalData = { + geodata, + 'Geo-Location': geoLocation, + latitude: lat, + longitude: lng, + ...additionalData, + created_at: new Date().toISOString(), + created_by: req.session.userEmail // Track who created the location + }; + + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; + + logger.info('Creating new location:', { lat, lng }); + + const response = await axios.post(url, finalData, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + }); + + logger.info('Location created successfully:', response.data.id); + + res.status(201).json({ + success: true, + location: response.data + }); + + } 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' + }); + } + } +}); + +// Update location +app.put('/api/locations/:id', strictLimiter, async (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 from update data to avoid conflicts - use the correct field name + delete updateData.ID; + delete updateData.Id; + delete updateData.id; + delete updateData._id; + + // Sync geo fields + updateData = syncGeoFields(updateData); + + updateData.last_updated_at = new Date().toISOString(); + updateData.last_updated_by = req.session.userEmail; // Track who updated + + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${locationId}`; + + logger.info(`Updating location ${locationId} by ${req.session.userEmail}`); + logger.debug('Update data:', updateData); + + const response = await axios.patch(url, updateData, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN, + 'Content-Type': 'application/json' + } + }); + + res.json({ + success: true, + location: response.data + }); + + } catch (error) { + logger.error(`Error updating location ${req.params.id}:`, error.message); + if (error.response) { + logger.error('Error response:', error.response.data); + } + + res.status(error.response?.status || 500).json({ + success: false, + error: 'Failed to update location', + details: error.response?.data?.message || error.message + }); + } +}); + +// Delete location +app.delete('/api/locations/:id', strictLimiter, async (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' + }); + } + + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${locationId}`; + + await axios.delete(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN + } + }); + + logger.info(`Location ${locationId} deleted by ${req.session.userEmail}`); + + 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' + }); + } +}); + +// Add a debug endpoint to check table structure +app.get('/api/debug/table-structure', requireAdmin, async (req, res) => { + try { + const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; + + const response = await axios.get(url, { + headers: { + 'xc-token': process.env.NOCODB_API_TOKEN + }, + params: { + limit: 1 + } + }); + + const sample = response.data.list?.[0] || {}; + + res.json({ + success: true, + fields: Object.keys(sample), + sampleRecord: sample, + idField: sample.ID ? 'ID' : (sample.Id ? 'Id' : (sample.id ? 'id' : 'unknown')) + }); + + } catch (error) { + logger.error('Error checking table structure:', error); + res.status(500).json({ + success: false, + error: 'Failed to check table structure' + }); + } +}); + +// QR code generation test endpoint (local only, no upload) +app.get('/api/debug/test-qr', requireAdmin, async (req, res) => { + try { + const testUrl = req.query.url || 'https://example.com/test'; + const testSize = parseInt(req.query.size) || 200; + + logger.info('Testing local QR code generation...'); + + const qrOptions = { + type: 'png', + width: testSize, + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' + }; + + const buffer = await generateQRCode(testUrl, qrOptions); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length + }); + + res.send(buffer); + + } catch (error) { + logger.error('QR code test failed:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Local QR code generation endpoint +app.get('/api/qr', async (req, res) => { + try { + const { text, size = 200 } = req.query; + + if (!text) { + return res.status(400).json({ + success: false, + error: 'Text parameter is required' + }); + } + + const { generateQRCode } = require('./services/qrcode'); + + const qrOptions = { + type: 'png', + width: parseInt(size), + margin: 1, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' + }; + + const buffer = await generateQRCode(text, qrOptions); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length, + 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour + }); + + res.send(buffer); + + } catch (error) { + logger.error('QR code generation error:', error); + res.status(500).json({ + success: false, + error: 'Failed to generate QR code' + }); + } +}); + +// Simple QR test page +app.get('/test-qr', (req, res) => { + res.send(` + + +
+Try accessing these URLs directly:
+ +Try accessing these URLs directly:
- -