diff --git a/map/app/Dockerfile b/map/app/Dockerfile
index 4965d2f..bbd45ee 100644
--- a/map/app/Dockerfile
+++ b/map/app/Dockerfile
@@ -1,7 +1,7 @@
FROM node:18-alpine
-# Install wget and dumb-init for proper signal handling
-RUN apk add --no-cache wget dumb-init
+# Install wget for health checks
+RUN apk add --no-cache wget
WORKDIR /app
@@ -33,6 +33,5 @@ USER nodejs
EXPOSE 3000
-# Use dumb-init to handle signals properly and prevent zombie processes
-ENTRYPOINT ["dumb-init", "--"]
+# Use exec form to ensure PID 1 is node process
CMD ["node", "server.js"]
\ No newline at end of file
diff --git a/map/app/package.json b/map/app/package.json
index f515133..30de2af 100644
--- a/map/app/package.json
+++ b/map/app/package.json
@@ -5,7 +5,6 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
diff --git a/map/app/public/css/modules/leaflet-custom.css b/map/app/public/css/modules/leaflet-custom.css
index 95f6e56..662943a 100644
--- a/map/app/public/css/modules/leaflet-custom.css
+++ b/map/app/public/css/modules/leaflet-custom.css
@@ -54,8 +54,8 @@
cursor: pointer;
}
-/* Ensure circle markers are visible */
-path.leaflet-interactive {
+/* Ensure circle markers are visible - but NOT cut polygons */
+path.leaflet-interactive:not(.cut-polygon) {
stroke: #fff;
stroke-opacity: 1;
stroke-width: 2;
@@ -107,7 +107,15 @@ path.leaflet-interactive {
/* Cut polygons - allow dynamic opacity (higher specificity to override) */
.leaflet-container path.leaflet-interactive.cut-polygon {
stroke-width: 2px !important;
- /* Allow JavaScript to control fill-opacity - remove !important */
+ /* Allow JavaScript to control fill-opacity - explicitly do NOT override it */
+ stroke: currentColor !important;
+ stroke-opacity: 0.8 !important;
+}
+
+/* Additional specificity rule for cut polygons to ensure JS opacity takes precedence */
+path.leaflet-interactive.cut-polygon {
+ /* Do not set fill-opacity here - let JavaScript control it */
+ stroke-width: 2px;
}
/* Marker being moved */
diff --git a/map/app/public/css/modules/start-location-marker.css b/map/app/public/css/modules/start-location-marker.css
index d4f38b0..c6593af 100644
--- a/map/app/public/css/modules/start-location-marker.css
+++ b/map/app/public/css/modules/start-location-marker.css
@@ -37,6 +37,10 @@
border-radius: 50%;
background: rgba(255, 68, 68, 0.3);
animation: pulse-ring 2s ease-out infinite;
+ /* Position the pulse at the tip of the marker */
+ top: 24px;
+ left: 0;
+ transform-origin: center center;
}
@keyframes bounce-marker {
@@ -69,4 +73,4 @@
.start-location-popup-enhanced .leaflet-popup-content {
margin: 0;
-}
+}
\ No newline at end of file
diff --git a/map/app/public/js/cut-manager.js b/map/app/public/js/cut-manager.js
index f0455de..53a9a34 100644
--- a/map/app/public/js/cut-manager.js
+++ b/map/app/public/js/cut-manager.js
@@ -183,92 +183,123 @@ export class CutManager {
return false;
}
}
-
- /**
- * Display a cut on the map (enhanced to support multiple cuts)
- */
- /**
- * Display a cut on the map (enhanced to support multiple cuts)
- */
- displayCut(cutData, autoDisplayed = false) {
- if (!this.map) {
- console.error('Map not initialized');
- return false;
- }
-
- // Normalize field names for consistent access
- const normalizedCut = {
- ...cutData,
- id: cutData.id || cutData.Id || cutData.ID,
- name: cutData.name || cutData.Name,
- description: cutData.description || cutData.Description,
- color: cutData.color || cutData.Color,
- opacity: cutData.opacity || cutData.Opacity,
- category: cutData.category || cutData.Category,
- geojson: cutData.geojson || cutData.GeoJSON || cutData['GeoJSON Data'],
- is_public: cutData.is_public || cutData['Public Visibility'],
- is_official: cutData.is_official || cutData['Official Cut'],
- autoDisplayed: autoDisplayed // Track if this was auto-displayed
- };
-
- // Check if already displayed
- if (this.cutLayers.has(normalizedCut.id)) {
- console.log(`Cut already displayed: ${normalizedCut.name}`);
- return true;
- }
-
- if (!normalizedCut.geojson) {
- console.error('Cut has no GeoJSON data');
- return false;
- }
-
- try {
- const geojsonData = typeof normalizedCut.geojson === 'string' ?
- JSON.parse(normalizedCut.geojson) : normalizedCut.geojson;
-
- const cutLayer = L.geoJSON(geojsonData, {
- style: {
- color: normalizedCut.color || '#3388ff',
- fillColor: normalizedCut.color || '#3388ff',
- fillOpacity: parseFloat(normalizedCut.opacity) || 0.3,
- weight: 2,
- opacity: 1,
- className: 'cut-polygon'
- }
- });
-
- // Add popup with cut info
- cutLayer.bindPopup(`
-
- `);
-
- cutLayer.addTo(this.map);
-
- // Store in both tracking systems
- this.cutLayers.set(normalizedCut.id, cutLayer);
- this.displayedCuts.set(normalizedCut.id, normalizedCut);
-
- // Update current cut reference (for legacy compatibility)
- this.currentCut = normalizedCut;
- this.currentCutLayer = cutLayer;
-
- console.log(`Displayed cut: ${normalizedCut.name} (ID: ${normalizedCut.id})`);
- return true;
-
- } catch (error) {
- console.error('Error displaying cut:', error);
- return false;
- }
+
+ // ...existing code...
+
+displayCut(cutData, autoDisplayed = false) {
+ if (!this.map) {
+ console.error('Map not initialized');
+ return false;
}
- /**
- * Hide the currently displayed cut (legacy method - now hides all cuts)
- */
+ // Normalize field names for consistent access
+ const normalizedCut = {
+ ...cutData,
+ id: cutData.id || cutData.Id || cutData.ID,
+ name: cutData.name || cutData.Name,
+ description: cutData.description || cutData.Description,
+ color: cutData.color || cutData.Color,
+ opacity: cutData.opacity || cutData.Opacity,
+ category: cutData.category || cutData.Category,
+ geojson: cutData.geojson || cutData.GeoJSON || cutData['GeoJSON Data'],
+ is_public: cutData.is_public || cutData['Public Visibility'],
+ is_official: cutData.is_official || cutData['Official Cut'],
+ autoDisplayed: autoDisplayed // Track if this was auto-displayed
+ };
+
+ // Check if already displayed
+ if (this.cutLayers.has(normalizedCut.id)) {
+ console.log(`Cut already displayed: ${normalizedCut.name}`);
+ return true;
+ }
+
+ if (!normalizedCut.geojson) {
+ console.error('Cut has no GeoJSON data');
+ return false;
+ }
+
+ try {
+ const geojsonData = typeof normalizedCut.geojson === 'string' ?
+ JSON.parse(normalizedCut.geojson) : normalizedCut.geojson;
+
+ // Parse opacity value - ensure it's a number between 0 and 1
+ let opacityValue = parseFloat(normalizedCut.opacity);
+
+ // Validate opacity is within range
+ if (isNaN(opacityValue) || opacityValue < 0 || opacityValue > 1) {
+ opacityValue = 0.3; // Default fallback
+ console.log(`Invalid opacity value (${normalizedCut.opacity}), using default: ${opacityValue}`);
+ }
+
+ const cutLayer = L.geoJSON(geojsonData, {
+ style: {
+ color: normalizedCut.color || '#3388ff',
+ fillColor: normalizedCut.color || '#3388ff',
+ fillOpacity: opacityValue,
+ weight: 2,
+ opacity: 0.8, // Stroke opacity - keeping this slightly transparent for better visibility
+ className: 'cut-polygon'
+ },
+ // Add onEachFeature to apply styles to each individual feature
+ onEachFeature: function (feature, layer) {
+ // Apply styles directly to the layer to ensure they override CSS
+ if (layer.setStyle) {
+ layer.setStyle({
+ fillOpacity: opacityValue,
+ color: normalizedCut.color || '#3388ff',
+ fillColor: normalizedCut.color || '#3388ff',
+ weight: 2,
+ opacity: 0.8
+ });
+ }
+
+ // Add cut-polygon class to the path element
+ if (layer._path) {
+ layer._path.classList.add('cut-polygon');
+ }
+ }
+ });
+
+ // Add popup with cut info
+ cutLayer.bindPopup(`
+
+ `);
+
+ cutLayer.addTo(this.map);
+
+ // Ensure cut-polygon class is applied to all path elements after adding to map
+ cutLayer.eachLayer(function(layer) {
+ if (layer._path) {
+ layer._path.classList.add('cut-polygon');
+ // Force the fill-opacity style to ensure it overrides CSS
+ layer._path.style.fillOpacity = opacityValue;
+ }
+ });
+
+ // Store in both tracking systems
+ this.cutLayers.set(normalizedCut.id, cutLayer);
+ this.displayedCuts.set(normalizedCut.id, normalizedCut);
+
+ // Update current cut reference (for legacy compatibility)
+ this.currentCut = normalizedCut;
+ this.currentCutLayer = cutLayer;
+
+ console.log(`Displayed cut: ${normalizedCut.name} (ID: ${normalizedCut.id}) with opacity: ${opacityValue} (raw: ${normalizedCut.opacity})`);
+ return true;
+
+ } catch (error) {
+ console.error('Error displaying cut:', error);
+ return false;
+ }
+}
+
+// ...existing code...
+
hideCut() {
this.hideAllCuts();
}
diff --git a/map/app/public/js/map.js.backup b/map/app/public/js/map.js.backup
deleted file mode 100644
index 712ce64..0000000
--- a/map/app/public/js/map.js.backup
+++ /dev/null
@@ -1,1031 +0,0 @@
-// Global configuration
-const CONFIG = {
- DEFAULT_LAT: parseFloat(document.querySelector('meta[name="default-lat"]')?.content) || 53.5461,
- DEFAULT_LNG: parseFloat(document.querySelector('meta[name="default-lng"]')?.content) || -113.4938,
- DEFAULT_ZOOM: parseInt(document.querySelector('meta[name="default-zoom"]')?.content) || 11,
- REFRESH_INTERVAL: 30000, // 30 seconds
- MAX_ZOOM: 19,
- MIN_ZOOM: 2
-};
-
-// Application state
-let map = null;
-let markers = [];
-let userLocationMarker = null;
-let isAddingLocation = false;
-let refreshInterval = null;
-let currentEditingLocation = null;
-let currentUser = null;
-let startLocationMarker = null;
-let isStartLocationVisible = true;
-
-// Initialize the application
-document.addEventListener('DOMContentLoaded', async () => {
- console.log('DOM loaded, initializing application...');
-
- try {
- // First check authentication
- await checkAuth();
-
- // Then initialize the map
- await initializeMap();
-
- // Only load locations after map is ready
- await loadLocations();
-
- // Setup other features
- setupEventListeners();
- setupAutoRefresh();
-
- } catch (error) {
- console.error('Initialization error:', error);
- showStatus('Failed to initialize application', 'error');
- } finally {
- hideLoading();
- }
-});
-
-// Check authentication
-async function checkAuth() {
- try {
- const response = await fetch('/api/auth/check');
- const data = await response.json();
-
- if (!data.authenticated) {
- window.location.href = '/login.html';
- // Throw error to stop further initialization
- throw new Error('Not authenticated');
- }
-
- currentUser = data.user;
- updateUserInterface();
-
- } catch (error) {
- console.error('Auth check failed:', error);
- window.location.href = '/login.html';
- // Re-throw to stop initialization chain
- throw error;
- }
-}
-
-// Update UI based on user
-function updateUserInterface() {
- if (!currentUser) return;
-
- // Update user email in both desktop and mobile
- const userEmailElement = document.getElementById('user-email');
- const mobileUserEmailElement = document.getElementById('mobile-user-email');
-
- if (userEmailElement) {
- userEmailElement.textContent = currentUser.email;
- }
- if (mobileUserEmailElement) {
- mobileUserEmailElement.textContent = currentUser.email;
- }
-
- // Add admin link if user is admin
- if (currentUser.isAdmin) {
- // Add admin link to desktop header
- const headerActions = document.querySelector('.header-actions');
- if (headerActions) {
- const adminLink = document.createElement('a');
- adminLink.href = '/admin.html';
- adminLink.className = 'btn btn-secondary';
- adminLink.textContent = '⚙️ Admin';
- headerActions.insertBefore(adminLink, headerActions.firstChild);
- }
-
- // Add admin link to mobile dropdown
- const mobileDropdownContent = document.getElementById('mobile-dropdown-content');
- if (mobileDropdownContent) {
- // Check if admin link already exists
- if (!mobileDropdownContent.querySelector('.admin-link-mobile')) {
- const adminItem = document.createElement('div');
- adminItem.className = 'mobile-dropdown-item admin-link-mobile';
-
- const adminLink = document.createElement('a');
- adminLink.href = '/admin.html';
- adminLink.style.color = 'inherit';
- adminLink.style.textDecoration = 'none';
- adminLink.textContent = '⚙️ Admin Panel';
-
- adminItem.appendChild(adminLink);
-
- // Insert admin link at the top of the dropdown
- if (mobileDropdownContent.firstChild) {
- mobileDropdownContent.insertBefore(adminItem, mobileDropdownContent.firstChild);
- } else {
- mobileDropdownContent.appendChild(adminItem);
- }
- }
- }
- }
-}
-
-// Initialize the map
-async function initializeMap() {
- try {
- // Get start location from server
- const response = await fetch('/api/admin/start-location');
- const data = await response.json();
-
- let startLat = CONFIG.DEFAULT_LAT;
- let startLng = CONFIG.DEFAULT_LNG;
- let startZoom = CONFIG.DEFAULT_ZOOM;
-
- if (data.success && data.location) {
- startLat = data.location.latitude;
- startLng = data.location.longitude;
- startZoom = data.location.zoom;
- }
-
- // Initialize map
- map = L.map('map').setView([startLat, startLng], startZoom);
-
- // Add tile layer
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- attribution: '© OpenStreetMap contributors',
- maxZoom: CONFIG.MAX_ZOOM,
- minZoom: CONFIG.MIN_ZOOM
- }).addTo(map);
-
- // Add start location marker
- addStartLocationMarker(startLat, startLng);
-
- console.log('Map initialized successfully');
-
- } catch (error) {
- console.error('Failed to initialize map:', error);
- showStatus('Failed to initialize map', 'error');
- }
-}
-
-// Add start location marker function
-function addStartLocationMarker(lat, lng) {
- console.log(`Adding start location marker at: ${lat}, ${lng}`);
-
- // Remove existing start location marker if it exists
- if (startLocationMarker) {
- map.removeLayer(startLocationMarker);
- }
-
- // Create a very distinctive custom icon
- const startIcon = L.divIcon({
- html: `
-
- `,
- className: 'start-location-custom-marker',
- iconSize: [48, 48],
- iconAnchor: [24, 48],
- popupAnchor: [0, -48]
- });
-
- // Create the marker
- startLocationMarker = L.marker([lat, lng], {
- icon: startIcon,
- zIndexOffset: 1000
- }).addTo(map);
-
- // Add popup
- startLocationMarker.bindPopup(`
-
- `);
-}
-
-// Toggle start location visibility
-function toggleStartLocationVisibility() {
- if (!startLocationMarker) return;
-
- isStartLocationVisible = !isStartLocationVisible;
-
- if (isStartLocationVisible) {
- map.addLayer(startLocationMarker);
- // Update both desktop and mobile button text
- const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text');
- if (desktopBtn) desktopBtn.textContent = 'Hide Start Location';
- } else {
- map.removeLayer(startLocationMarker);
- // Update both desktop and mobile button text
- const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text');
- if (desktopBtn) desktopBtn.textContent = 'Show Start Location';
- }
-}
-
-// Load locations from the API
-async function loadLocations() {
- try {
- const response = await fetch('/api/locations');
- const data = await response.json();
-
- if (data.success) {
- displayLocations(data.locations);
- updateLocationCount(data.locations.length);
- } else {
- throw new Error(data.error || 'Failed to load locations');
- }
- } catch (error) {
- console.error('Error loading locations:', error);
- showStatus('Failed to load locations', 'error');
- }
-}
-
-// Display locations on the map
-function displayLocations(locations) {
- // Clear existing markers
- markers.forEach(marker => {
- if (marker && map) {
- map.removeLayer(marker);
- }
- });
- markers = [];
-
- // Add new markers
- locations.forEach(location => {
- if (location.latitude && location.longitude) {
- const marker = createLocationMarker(location);
- if (marker) { // Only add if marker was successfully created
- markers.push(marker);
- }
- }
- });
-
- console.log(`Displayed ${markers.length} locations`);
-}
-
-// Create a location marker
-function createLocationMarker(location) {
- // Safety check - ensure map exists
- if (!map) {
- console.warn('Map not initialized, skipping marker creation');
- return null;
- }
-
- const lat = parseFloat(location.latitude);
- const lng = parseFloat(location.longitude);
-
- // Determine marker color based on support level
- let markerColor = 'blue';
- if (location['Support Level']) {
- const level = parseInt(location['Support Level']);
- switch(level) {
- case 1: markerColor = 'green'; break;
- case 2: markerColor = 'yellow'; break;
- case 3: markerColor = 'orange'; break;
- case 4: markerColor = 'red'; break;
- }
- }
-
- const marker = L.circleMarker([lat, lng], {
- radius: 8,
- fillColor: markerColor,
- color: '#fff',
- weight: 2,
- opacity: 1,
- fillOpacity: 0.8
- }).addTo(map);
-
- // Create popup content
- const popupContent = createPopupContent(location);
- marker.bindPopup(popupContent);
-
- // Store location data on marker for later use
- marker._locationData = location;
-
- return marker;
-}
-
-// Create popup content
-function createPopupContent(location) {
- // Try to find the ID field
- const locationId = location.Id || location.id || location.ID || location._id;
-
- const name = [location['First Name'], location['Last Name']]
- .filter(Boolean).join(' ') || 'Unknown';
- const address = location.Address || 'No address';
- const supportLevel = location['Support Level'] ?
- `Level ${location['Support Level']}` : 'Not specified';
-
- return `
-
- `;
-}
-
-// Setup event listeners
-function setupEventListeners() {
- // Desktop controls
- document.getElementById('refresh-btn')?.addEventListener('click', () => {
- loadLocations();
- showStatus('Locations refreshed', 'success');
- });
-
- document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
- document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
- document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
- document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen);
-
- // Mobile controls
- document.getElementById('mobile-refresh-btn')?.addEventListener('click', () => {
- loadLocations();
- showStatus('Locations refreshed', 'success');
- });
-
- document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
- document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
- document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
- document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
-
- // Mobile dropdown toggle
- document.getElementById('mobile-dropdown-toggle')?.addEventListener('click', (e) => {
- e.stopPropagation();
- const dropdown = document.getElementById('mobile-dropdown');
- dropdown.classList.toggle('active');
- });
-
- // Close mobile dropdown when clicking outside
- document.addEventListener('click', (e) => {
- const dropdown = document.getElementById('mobile-dropdown');
- if (!dropdown.contains(e.target)) {
- dropdown.classList.remove('active');
- }
- });
-
- // Modal controls
- document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal);
- document.getElementById('cancel-modal-btn')?.addEventListener('click', closeAddModal);
-
- // Edit footer controls
- document.getElementById('close-edit-footer-btn')?.addEventListener('click', closeEditForm);
-
- // Forms
- document.getElementById('location-form')?.addEventListener('submit', handleAddLocation);
- document.getElementById('edit-location-form')?.addEventListener('submit', handleEditLocation);
-
- // Delete button
- document.getElementById('delete-location-btn')?.addEventListener('click', handleDeleteLocation);
-
- // Address lookup buttons
- document.getElementById('lookup-address-add-btn')?.addEventListener('click', () => {
- lookupAddress('add');
- });
-
- document.getElementById('lookup-address-edit-btn')?.addEventListener('click', () => {
- lookupAddress('edit');
- });
-
- // Geo-location field sync
- setupGeoLocationSync();
-
- // Add event delegation for popup edit buttons
- document.addEventListener('click', (e) => {
- if (e.target.classList.contains('edit-location-popup-btn')) {
- e.preventDefault();
- try {
- const locationData = JSON.parse(e.target.getAttribute('data-location'));
- openEditForm(locationData);
- } catch (error) {
- console.error('Error parsing location data:', error);
- showStatus('Error opening edit form', 'error');
- }
- }
- });
-}
-
-// Setup geo-location field synchronization
-function setupGeoLocationSync() {
- // For add form
- const addLatInput = document.getElementById('location-lat');
- const addLngInput = document.getElementById('location-lng');
- const addGeoInput = document.getElementById('geo-location');
-
- if (addLatInput && addLngInput && addGeoInput) {
- [addLatInput, addLngInput].forEach(input => {
- input.addEventListener('input', () => {
- const lat = addLatInput.value;
- const lng = addLngInput.value;
- if (lat && lng) {
- addGeoInput.value = `${lat};${lng}`;
- }
- });
- });
-
- addGeoInput.addEventListener('input', () => {
- const coords = parseGeoLocation(addGeoInput.value);
- if (coords) {
- addLatInput.value = coords.lat;
- addLngInput.value = coords.lng;
- }
- });
- }
-
- // For edit form
- const editLatInput = document.getElementById('edit-location-lat');
- const editLngInput = document.getElementById('edit-location-lng');
- const editGeoInput = document.getElementById('edit-geo-location');
-
- if (editLatInput && editLngInput && editGeoInput) {
- [editLatInput, editLngInput].forEach(input => {
- input.addEventListener('input', () => {
- const lat = editLatInput.value;
- const lng = editLngInput.value;
- if (lat && lng) {
- editGeoInput.value = `${lat};${lng}`;
- }
- });
- });
-
- editGeoInput.addEventListener('input', () => {
- const coords = parseGeoLocation(editGeoInput.value);
- if (coords) {
- editLatInput.value = coords.lat;
- editLngInput.value = coords.lng;
- }
- });
- }
-}
-
-// Parse geo-location string
-function parseGeoLocation(value) {
- if (!value) return null;
-
- // Try semicolon separator first
- let parts = value.split(';');
- if (parts.length !== 2) {
- // Try comma separator
- parts = value.split(',');
- }
-
- if (parts.length === 2) {
- const lat = parseFloat(parts[0].trim());
- const lng = parseFloat(parts[1].trim());
-
- if (!isNaN(lat) && !isNaN(lng)) {
- return { lat, lng };
- }
- }
-
- return null;
-}
-
-// Get user location
-function getUserLocation() {
- if (!navigator.geolocation) {
- showStatus('Geolocation is not supported by your browser', 'error');
- return;
- }
-
- showStatus('Getting your location...', 'info');
-
- navigator.geolocation.getCurrentPosition(
- (position) => {
- const lat = position.coords.latitude;
- const lng = position.coords.longitude;
-
- // Center map on user location
- map.setView([lat, lng], 15);
-
- // Add or update user location marker
- if (userLocationMarker) {
- userLocationMarker.setLatLng([lat, lng]);
- } else {
- userLocationMarker = L.circleMarker([lat, lng], {
- radius: 10,
- fillColor: '#2196F3',
- color: '#fff',
- weight: 3,
- opacity: 1,
- fillOpacity: 0.8
- }).addTo(map);
-
- userLocationMarker.bindPopup('Your Location');
- }
-
- showStatus('Location found!', 'success');
- },
- (error) => {
- let message = 'Unable to get your location';
- switch(error.code) {
- case error.PERMISSION_DENIED:
- message = 'Location permission denied';
- break;
- case error.POSITION_UNAVAILABLE:
- message = 'Location information unavailable';
- break;
- case error.TIMEOUT:
- message = 'Location request timed out';
- break;
- }
- showStatus(message, 'error');
- },
- {
- enableHighAccuracy: true,
- timeout: 10000,
- maximumAge: 0
- }
- );
-}
-
-// Toggle add location mode
-function toggleAddLocationMode() {
- isAddingLocation = !isAddingLocation;
-
- const crosshair = document.getElementById('crosshair');
- const addBtn = document.getElementById('add-location-btn');
- const mobileAddBtn = document.getElementById('mobile-add-location-btn');
-
- if (isAddingLocation) {
- crosshair.classList.remove('hidden');
-
- // Update desktop button
- if (addBtn) {
- addBtn.classList.add('active');
- addBtn.innerHTML = '✕Cancel';
- }
-
- // Update mobile button
- if (mobileAddBtn) {
- mobileAddBtn.classList.add('active');
- mobileAddBtn.innerHTML = '✕';
- mobileAddBtn.title = 'Cancel';
- }
-
- map.on('click', handleMapClick);
- } else {
- crosshair.classList.add('hidden');
-
- // Update desktop button
- if (addBtn) {
- addBtn.classList.remove('active');
- addBtn.innerHTML = '➕Add Location Here';
- }
-
- // Update mobile button
- if (mobileAddBtn) {
- mobileAddBtn.classList.remove('active');
- mobileAddBtn.innerHTML = '➕';
- mobileAddBtn.title = 'Add Location';
- }
-
- map.off('click', handleMapClick);
- }
-}
-
-// Handle map click in add mode
-function handleMapClick(e) {
- if (!isAddingLocation) return;
-
- const { lat, lng } = e.latlng;
- openAddModal(lat, lng);
- toggleAddLocationMode();
-}
-
-// Open add location modal
-function openAddModal(lat, lng) {
- const modal = document.getElementById('add-modal');
- const latInput = document.getElementById('location-lat');
- const lngInput = document.getElementById('location-lng');
- const geoInput = document.getElementById('geo-location');
-
- // Set coordinates
- latInput.value = lat.toFixed(8);
- lngInput.value = lng.toFixed(8);
- geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
-
- // Clear other fields
- document.getElementById('location-form').reset();
- latInput.value = lat.toFixed(8);
- lngInput.value = lng.toFixed(8);
- geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
-
- // Show modal
- modal.classList.remove('hidden');
-}
-
-// Close add modal
-function closeAddModal() {
- const modal = document.getElementById('add-modal');
- modal.classList.add('hidden');
- document.getElementById('location-form').reset();
-}
-
-// Handle add location form submission
-async function handleAddLocation(e) {
- e.preventDefault();
-
- const formData = new FormData(e.target);
- const data = {};
-
- // Convert form data to object
- for (let [key, value] of formData.entries()) {
- // Map form field names to NocoDB column names
- if (key === 'latitude') data.latitude = value.trim();
- else if (key === 'longitude') data.longitude = value.trim();
- else if (key === 'Geo-Location') data['Geo-Location'] = value.trim();
- else if (value.trim() !== '') {
- data[key] = value.trim();
- }
- }
-
- // Ensure geo-location is set
- if (data.latitude && data.longitude) {
- data['Geo-Location'] = `${data.latitude};${data.longitude}`;
- }
-
- // Handle checkbox
- data.Sign = document.getElementById('sign').checked;
-
- try {
- const response = await fetch('/api/locations', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(data)
- });
-
- const result = await response.json();
-
- if (result.success) {
- showStatus('Location added successfully!', 'success');
- closeAddModal();
- loadLocations();
- } else {
- throw new Error(result.error || 'Failed to add location');
- }
- } catch (error) {
- console.error('Error adding location:', error);
- showStatus(error.message || 'Failed to add location', 'error');
- }
-}
-
-// Open edit form
-function openEditForm(location) {
- currentEditingLocation = location;
-
- // Debug: Log all possible ID fields
- console.log('Opening edit form for location:', {
- 'Id': location.Id,
- 'id': location.id,
- 'ID': location.ID,
- '_id': location._id,
- 'all_keys': Object.keys(location)
- });
-
- // Extract ID - check multiple possible field names
- const locationId = location.Id || location.id || location.ID || location._id;
-
- if (!locationId) {
- console.error('No ID found in location object. Available fields:', Object.keys(location));
- showStatus('Error: Location ID not found. Check console for details.', 'error');
- return;
- }
-
- // Store the ID in a data attribute for later use
- document.getElementById('edit-location-id').value = locationId;
- document.getElementById('edit-location-id').setAttribute('data-location-id', locationId);
-
- // Populate form fields
- document.getElementById('edit-first-name').value = location['First Name'] || '';
- document.getElementById('edit-last-name').value = location['Last Name'] || '';
- document.getElementById('edit-location-email').value = location.Email || '';
- document.getElementById('edit-location-phone').value = location.Phone || '';
- document.getElementById('edit-location-unit').value = location['Unit Number'] || '';
- document.getElementById('edit-support-level').value = location['Support Level'] || '';
- document.getElementById('edit-location-address').value = location.Address || '';
- document.getElementById('edit-sign').checked = location.Sign === true || location.Sign === 'true' || location.Sign === 1;
- document.getElementById('edit-sign-size').value = location['Sign Size'] || '';
- document.getElementById('edit-location-notes').value = location.Notes || '';
- document.getElementById('edit-location-lat').value = location.latitude || '';
- document.getElementById('edit-location-lng').value = location.longitude || '';
- document.getElementById('edit-geo-location').value = location['Geo-Location'] || '';
-
- // Show edit footer
- document.getElementById('edit-footer').classList.remove('hidden');
-}
-
-// Close edit form
-function closeEditForm() {
- document.getElementById('edit-footer').classList.add('hidden');
- currentEditingLocation = null;
-}
-
-// Handle edit location form submission
-async function handleEditLocation(e) {
- e.preventDefault();
-
- if (!currentEditingLocation) return;
-
- // Get the stored location ID
- const locationIdElement = document.getElementById('edit-location-id');
- const locationId = locationIdElement.getAttribute('data-location-id') || locationIdElement.value;
-
- if (!locationId || locationId === 'undefined') {
- showStatus('Error: Location ID not found', 'error');
- return;
- }
-
- const formData = new FormData(e.target);
- const data = {};
-
- // Convert form data to object
- for (let [key, value] of formData.entries()) {
- // Skip the ID field
- if (key === 'id' || key === 'Id' || key === 'ID') continue;
-
- if (value !== null && value !== undefined) {
- // Don't skip empty strings - they may be intentional field clearing
- data[key] = value.trim();
- }
- }
-
- // Ensure geo-location is set
- if (data.latitude && data.longitude) {
- data['Geo-Location'] = `${data.latitude};${data.longitude}`;
- }
-
- // Handle checkbox
- data.Sign = document.getElementById('edit-sign').checked;
-
- // Add debugging
- console.log('Sending update data for ID:', locationId);
- console.log('Update data:', data);
-
- try {
- const response = await fetch(`/api/locations/${locationId}`, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify(data)
- });
-
- const responseText = await response.text();
- let result;
-
- try {
- result = JSON.parse(responseText);
- } catch (e) {
- console.error('Failed to parse response:', responseText);
- throw new Error(`Server response error: ${response.status} ${response.statusText}`);
- }
-
- if (result.success) {
- showStatus('Location updated successfully!', 'success');
- closeEditForm();
- loadLocations();
- } else {
- throw new Error(result.error || 'Failed to update location');
- }
- } catch (error) {
- console.error('Error updating location:', error);
- showStatus(`Update failed: ${error.message}`, 'error');
- }
-}
-
-// Handle delete location
-async function handleDeleteLocation() {
- if (!currentEditingLocation) return;
-
- // Get the stored location ID
- const locationIdElement = document.getElementById('edit-location-id');
- const locationId = locationIdElement.getAttribute('data-location-id') || locationIdElement.value;
-
- if (!locationId || locationId === 'undefined') {
- showStatus('Error: Location ID not found', 'error');
- return;
- }
-
- if (!confirm('Are you sure you want to delete this location?')) {
- return;
- }
-
- try {
- const response = await fetch(`/api/locations/${locationId}`, {
- method: 'DELETE'
- });
-
- const result = await response.json();
-
- if (result.success) {
- showStatus('Location deleted successfully!', 'success');
- closeEditForm();
- loadLocations();
- } else {
- throw new Error(result.error || 'Failed to delete location');
- }
- } catch (error) {
- console.error('Error deleting location:', error);
- showStatus(error.message || 'Failed to delete location', 'error');
- }
-}
-
-// Lookup address based on current coordinates
-async function lookupAddress(mode) {
- let latInput, lngInput, addressInput;
-
- if (mode === 'add') {
- latInput = document.getElementById('location-lat');
- lngInput = document.getElementById('location-lng');
- addressInput = document.getElementById('location-address');
- } else if (mode === 'edit') {
- latInput = document.getElementById('edit-location-lat');
- lngInput = document.getElementById('edit-location-lng');
- addressInput = document.getElementById('edit-location-address');
- } else {
- console.error('Invalid lookup mode:', mode);
- return;
- }
-
- if (!latInput || !lngInput || !addressInput) {
- showStatus('Form elements not found', 'error');
- return;
- }
-
- const lat = parseFloat(latInput.value);
- const lng = parseFloat(lngInput.value);
-
- if (isNaN(lat) || isNaN(lng)) {
- showStatus('Please enter valid coordinates first', 'warning');
- return;
- }
-
- // Show loading state
- const button = mode === 'add' ?
- document.getElementById('lookup-address-add-btn') :
- document.getElementById('lookup-address-edit-btn');
-
- const originalText = button ? button.textContent : '';
- if (button) {
- button.disabled = true;
- button.textContent = 'Looking up...';
- }
-
- try {
- console.log(`Looking up address for: ${lat}, ${lng}`);
- const response = await fetch(`/api/geocode/reverse?lat=${lat}&lng=${lng}`);
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`Geocoding failed: ${response.status} ${errorText}`);
- }
-
- const data = await response.json();
-
- if (data.success && data.data) {
- // Use the formatted address or full address
- const address = data.data.formattedAddress || data.data.fullAddress;
- if (address) {
- addressInput.value = address;
- showStatus('Address found!', 'success');
- } else {
- showStatus('No address found for these coordinates', 'warning');
- }
- } else {
- showStatus('Address lookup failed', 'warning');
- }
-
- } catch (error) {
- console.error('Address lookup error:', error);
- showStatus(`Address lookup failed: ${error.message}`, 'error');
- } finally {
- // Restore button state
- if (button) {
- button.disabled = false;
- button.textContent = originalText;
- }
- }
-}
-
-// Toggle fullscreen
-function toggleFullscreen() {
- const app = document.getElementById('app');
- const btn = document.getElementById('fullscreen-btn');
- const mobileBtn = document.getElementById('mobile-fullscreen-btn');
-
- if (!document.fullscreenElement) {
- app.requestFullscreen().then(() => {
- app.classList.add('fullscreen');
-
- // Update desktop button
- if (btn) {
- btn.innerHTML = '◱Exit Fullscreen';
- }
-
- // Update mobile button
- if (mobileBtn) {
- mobileBtn.innerHTML = '◱';
- mobileBtn.title = 'Exit Fullscreen';
- }
- }).catch(err => {
- console.error('Error entering fullscreen:', err);
- showStatus('Unable to enter fullscreen', 'error');
- });
- } else {
- document.exitFullscreen().then(() => {
- app.classList.remove('fullscreen');
-
- // Update desktop button
- if (btn) {
- btn.innerHTML = '⛶Fullscreen';
- }
-
- // Update mobile button
- if (mobileBtn) {
- mobileBtn.innerHTML = '⛶';
- mobileBtn.title = 'Fullscreen';
- }
- });
- }
-}
-
-// Update location count
-function updateLocationCount(count) {
- const countElement = document.getElementById('location-count');
- const mobileCountElement = document.getElementById('mobile-location-count');
-
- const countText = `${count} location${count !== 1 ? 's' : ''}`;
-
- if (countElement) {
- countElement.textContent = countText;
- }
- if (mobileCountElement) {
- mobileCountElement.textContent = countText;
- }
-}
-
-// Setup auto-refresh
-function setupAutoRefresh() {
- refreshInterval = setInterval(() => {
- loadLocations();
- }, CONFIG.REFRESH_INTERVAL);
-}
-
-// Show status message
-function showStatus(message, type = 'info') {
- const container = document.getElementById('status-container');
-
- const messageDiv = document.createElement('div');
- messageDiv.className = `status-message ${type}`;
- messageDiv.textContent = message;
-
- container.appendChild(messageDiv);
-
- // Auto-remove after 5 seconds
- setTimeout(() => {
- messageDiv.remove();
- }, 5000);
-}
-
-// Hide loading overlay
-function hideLoading() {
- const loading = document.getElementById('loading');
- if (loading) {
- loading.classList.add('hidden');
- }
-}
-
-// Escape HTML for security
-function escapeHtml(text) {
- if (text === null || text === undefined) {
- return '';
- }
- const div = document.createElement('div');
- div.textContent = String(text);
- return div.innerHTML;
-}
-
-// Clean up on page unload
-window.addEventListener('beforeunload', () => {
- if (refreshInterval) {
- clearInterval(refreshInterval);
- }
-});
diff --git a/map/app/server copy.js b/map/app/server copy.js
deleted file mode 100644
index 0c4e7a1..0000000
--- a/map/app/server copy.js
+++ /dev/null
@@ -1,2051 +0,0 @@
-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(`
-
-
-
- QR Code Test
-
-
-
- QR Code Generation Test
-
-
-
Test 1: Direct API Call
-
Try accessing these URLs directly:
-
-
-
-
-
Test 2: Dynamic Generation
-
-
-
-
-
-
-
Test 3: Using QRCode Library (like admin panel)
-
-
-
-
-
-
-
-
- `);
-});
-
-// Debug walk sheet configuration endpoint
-app.get('/api/debug/walk-sheet-config', requireAdmin, async (req, res) => {
- try {
- const debugInfo = {
- settingsSheetId: SETTINGS_SHEET_ID,
- settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
- hasSettingsSheet: !!SETTINGS_SHEET_ID,
- timestamp: new Date().toISOString()
- };
-
- if (!SETTINGS_SHEET_ID) {
- return res.json({
- success: true,
- debug: debugInfo,
- message: 'Settings sheet not configured'
- });
- }
-
- // Test connection to settings sheet
- const testUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`;
-
- const response = await axios.get(testUrl, {
- headers: {
- 'xc-token': process.env.NOCODB_API_TOKEN,
- 'Content-Type': 'application/json'
- },
- params: {
- limit: 5,
- sort: '-created_at'
- }
- });
-
- const records = response.data.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: SETTINGS_SHEET_ID,
- settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
- hasSettingsSheet: !!SETTINGS_SHEET_ID,
- timestamp: new Date().toISOString(),
- error: error.message,
- errorDetails: error.response?.data
- }
- });
- }
-});
-
-// Test walk sheet configuration endpoint
-app.post('/api/debug/test-walk-sheet-save', requireAdmin, 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...');
-
- // Create a test request object
- const testReq = {
- body: testConfig,
- session: {
- userEmail: req.session.userEmail,
- authenticated: true,
- isAdmin: true
- }
- };
-
- // Create a test response object
- let testResult = null;
- let testError = null;
-
- const testRes = {
- json: (data) => { testResult = data; },
- status: (code) => ({
- json: (data) => {
- testResult = data;
- testResult.statusCode = code;
- }
- })
- };
-
- // Test the save functionality
- if (!SETTINGS_SHEET_ID) {
- return res.json({
- success: false,
- test: 'failed',
- error: 'Settings sheet not configured',
- config: testConfig
- });
- }
-
- const userEmail = req.session.userEmail;
- const timestamp = new Date().toISOString();
-
- const walkSheetData = {
- created_at: timestamp,
- created_by: userEmail,
- walk_sheet_title: testConfig.walk_sheet_title,
- walk_sheet_subtitle: testConfig.walk_sheet_subtitle,
- walk_sheet_footer: testConfig.walk_sheet_footer,
- qr_code_1_url: testConfig.qr_code_1_url,
- qr_code_1_label: testConfig.qr_code_1_label,
- qr_code_2_url: testConfig.qr_code_2_url,
- qr_code_2_label: testConfig.qr_code_2_label,
- qr_code_3_url: testConfig.qr_code_3_url,
- qr_code_3_label: testConfig.qr_code_3_label
- };
-
- 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'
- }
- }
- );
-
- res.json({
- success: true,
- test: 'passed',
- message: 'Test walk sheet configuration saved successfully',
- testData: walkSheetData,
- saveResponse: response.data,
- settingsId: response.data.id || response.data.Id || response.data.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()
- });
- }
-});
-
-// Debug endpoint to see raw walk sheet data
-app.get('/api/debug/walk-sheet-raw', requireAdmin, async (req, res) => {
- try {
- if (!SETTINGS_SHEET_ID) {
- return res.json({ error: 'No settings sheet ID configured' });
- }
-
- 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
- },
- params: {
- sort: '-created_at',
- limit: 5
- }
- }
- );
-
- return res.json({
- success: true,
- tableId: SETTINGS_SHEET_ID,
- records: response.data?.list || [],
- count: response.data?.list?.length || 0
- });
- } catch (error) {
- logger.error('Error fetching raw walk sheet data:', error);
- return res.status(500).json({
- success: false,
- error: error.message
- });
- }
-});
-
-// Admin user management endpoints
-app.get('/api/admin/users', requireAdmin, async (req, res) => {
- try {
- if (!LOGIN_SHEET_ID) {
- return res.status(500).json({
- success: false,
- error: 'Login sheet not configured'
- });
- }
-
- const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}`;
-
- const response = await axios.get(url, {
- headers: {
- 'xc-token': process.env.NOCODB_API_TOKEN,
- 'Content-Type': 'application/json'
- },
- params: {
- limit: 100,
- sort: '-created_at'
- }
- });
-
- const users = response.data.list || [];
-
- // Remove password field from response for security
- const safeUsers = users.map(user => {
- const { Password, password, ...safeUser } = user;
- return safeUser;
- });
-
- 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'
- });
- }
-});
-
-app.post('/api/admin/users', requireAdmin, async (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 (!LOGIN_SHEET_ID) {
- return res.status(500).json({
- success: false,
- error: 'Login sheet not configured'
- });
- }
-
- // Check if user already exists
- const checkUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}`;
-
- const checkResponse = await axios.get(checkUrl, {
- headers: {
- 'xc-token': process.env.NOCODB_API_TOKEN,
- 'Content-Type': 'application/json'
- },
- params: {
- where: `(Email,eq,${email})`,
- limit: 1
- }
- });
-
- if (checkResponse.data.list && checkResponse.data.list.length > 0) {
- 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 axios.post(checkUrl, userData, {
- headers: {
- 'xc-token': process.env.NOCODB_API_TOKEN,
- 'Content-Type': 'application/json'
- }
- });
-
- res.status(201).json({
- success: true,
- message: 'User created successfully',
- user: {
- id: response.data.Id || response.data.id || response.data.ID,
- 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'
- });
- }
-});
-
-app.delete('/api/admin/users/:id', requireAdmin, async (req, res) => {
- try {
- const userId = req.params.id;
-
- if (!LOGIN_SHEET_ID) {
- 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'
- });
- }
-
- const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${LOGIN_SHEET_ID}/${userId}`;
-
- await axios.delete(url, {
- headers: {
- 'xc-token': process.env.NOCODB_API_TOKEN
- }
- });
-
- 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'
- });
- }
-});
-
-// Error handling middleware
-app.use((err, req, res, next) => {
- logger.error('Unhandled error:', err);
- res.status(500).json({
- success: false,
- error: 'Internal server error'
- });
-});
-
-// Start server
-app.listen(PORT, () => {
- logger.info(`
- ╔════════════════════════════════════════╗
- ║ BNKops Map Server ║
- ╠════════════════════════════════════════╣
- ║ Status: Running ║
- ║ Port: ${PORT} ║
- ║ Environment: ${process.env.NODE_ENV || 'development'} ║
- ║ Project ID: ${process.env.NOCODB_PROJECT_ID} ║
- ║ Table ID: ${process.env.NOCODB_TABLE_ID} ║
- ║ Login Sheet: ${LOGIN_SHEET_ID || 'Not Configured'} ║
- ║ Time: ${new Date().toISOString()} ║
- ╚════════════════════════════════════════╝
- `);
-});
-
-// Graceful shutdown
-process.on('SIGTERM', () => {
- logger.info('SIGTERM signal received: closing HTTP server');
- app.close(() => {
- logger.info('HTTP server closed');
- process.exit(0);
- });
-});
-
diff --git a/map/app/server.js b/map/app/server.js
index a22ff30..9995646 100644
--- a/map/app/server.js
+++ b/map/app/server.js
@@ -1,6 +1,16 @@
+// At the very top of the file, before any requires
+const startTime = Date.now();
+
+// Use a more robust check for duplicate execution
+if (global.__serverInitialized) {
+ console.log(`[INIT] Server already initialized - EXITING`);
+ return;
+}
+global.__serverInitialized = true;
+
// Prevent duplicate execution
if (require.main !== module) {
- console.log('Server.js being imported, not executed directly');
+ console.log('[INIT] Server.js being imported, not executed directly - EXITING');
return;
}
@@ -12,10 +22,6 @@ const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const fetch = require('node-fetch');
-// Debug: Check if server.js is being loaded multiple times
-const serverInstanceId = Math.random().toString(36).substr(2, 9);
-console.log(`[DEBUG] Server.js PID:${process.pid} instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
-
// Import configuration and utilities
const config = require('./config');
const logger = require('./utils/logger');
@@ -24,8 +30,14 @@ const { apiLimiter } = require('./middleware/rateLimiter');
const { cacheBusting } = require('./utils/cacheBusting');
const { initializeEmailService } = require('./services/email');
-// Initialize Express app
+// Initialize Express app - only create once
+if (global.__expressApp) {
+ console.log('[INIT] Express app already created - EXITING');
+ return;
+}
+
const app = express();
+global.__expressApp = app;
// Trust proxy for Cloudflare
app.set('trust proxy', true);
@@ -33,8 +45,8 @@ app.set('trust proxy', true);
// Cookie parser
app.use(cookieParser());
-// Session configuration
-app.use(session({
+// Session configuration - only initialize once
+const sessionMiddleware = session({
secret: config.session.secret,
resave: false,
saveUninitialized: false,
@@ -44,36 +56,34 @@ app.use(session({
// Use a custom session ID generator to avoid conflicts
return crypto.randomBytes(16).toString('hex');
}
-}));
+});
+
+app.use(sessionMiddleware);
// Build dynamic CSP configuration
const buildConnectSrc = () => {
const sources = ["'self'"];
- // Add MkDocs URLs from config
- if (config.mkdocs?.url) {
- sources.push(config.mkdocs.url);
+ // Add NocoDB API URL
+ if (config.nocodb.apiUrl) {
+ try {
+ const nocodbUrl = new URL(config.nocodb.apiUrl);
+ sources.push(`${nocodbUrl.protocol}//${nocodbUrl.host}`);
+ } catch (e) {
+ // Invalid URL, skip
+ }
}
- // Add localhost ports from environment
- const mkdocsPort = process.env.MKDOCS_PORT || '4000';
- const mkdocsSitePort = process.env.MKDOCS_SITE_SERVER_PORT || '4002';
-
- sources.push(`http://localhost:${mkdocsPort}`);
- sources.push(`http://localhost:${mkdocsSitePort}`);
-
- // Add City of Edmonton Socrata API
+ // Add Edmonton Open Data Portal
sources.push('https://data.edmonton.ca');
- // Add Stadia Maps for better tile coverage
- sources.push('https://tiles.stadiamaps.com');
+ // Add Nominatim for geocoding
+ sources.push('https://nominatim.openstreetmap.org');
- // Add production domains if in production
- if (config.isProduction || process.env.NODE_ENV === 'production') {
- // Add the main domain from environment
- const mainDomain = process.env.DOMAIN || 'cmlite.org';
- sources.push(`https://${mainDomain}`);
- sources.push('https://cmlite.org'); // Fallback
+ // Add localhost for development
+ if (!config.isProduction) {
+ sources.push('http://localhost:*');
+ sources.push('ws://localhost:*');
}
return sources;
@@ -95,14 +105,26 @@ app.use(helmet({
// CORS configuration
app.use(cors({
origin: function(origin, callback) {
- // Allow requests with no origin (like mobile apps or curl requests)
+ // Allow requests with no origin (like Postman or server-to-server)
if (!origin) return callback(null, true);
- const allowedOrigins = config.cors.allowedOrigins;
- if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
- callback(null, true);
+ // In production, be more restrictive
+ if (config.isProduction) {
+ const allowedOrigins = [
+ `https://${config.domain}`,
+ `https://map.${config.domain}`,
+ `https://docs.${config.domain}`,
+ `https://admin.${config.domain}`
+ ];
+
+ if (allowedOrigins.includes(origin)) {
+ callback(null, true);
+ } else {
+ callback(new Error('Not allowed by CORS'));
+ }
} else {
- callback(new Error('Not allowed by CORS'));
+ // In development, allow localhost
+ callback(null, true);
}
},
credentials: true,
@@ -127,7 +149,7 @@ app.use('/api/', apiLimiter);
// Cache busting version endpoint
app.get('/api/version', (req, res) => {
- res.json({
+ res.json({
version: cacheBusting.getVersion(),
timestamp: new Date().toISOString()
});
@@ -136,25 +158,24 @@ app.get('/api/version', (req, res) => {
// Proxy endpoint for MkDocs search
app.get('/api/docs-search', async (req, res) => {
try {
- const mkdocsUrl = config.mkdocs?.url || `http://localhost:${process.env.MKDOCS_SITE_SERVER_PORT || '4002'}`;
- logger.info(`Fetching search index from: ${mkdocsUrl}/search/search_index.json`);
-
- const response = await fetch(`${mkdocsUrl}/search/search_index.json`);
-
- if (!response.ok) {
- throw new Error(`Failed to fetch search index: ${response.status}`);
- }
+ const docsUrl = config.isProduction ?
+ `https://docs.${config.domain}/search/search_index.json` :
+ 'http://localhost:8000/search/search_index.json';
+ const response = await fetch(docsUrl);
const data = await response.json();
res.json(data);
} catch (error) {
- logger.error('Error fetching search index:', error);
+ logger.error('Failed to fetch docs search index:', error);
res.status(500).json({ error: 'Failed to fetch search index' });
}
});
-// Initialize email service
-initializeEmailService();
+// Initialize email service - only once
+if (!global.__emailInitialized) {
+ initializeEmailService();
+ global.__emailInitialized = true;
+}
// Import and setup routes
require('./routes')(app);
@@ -162,63 +183,70 @@ require('./routes')(app);
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
-
- // Don't leak error details in production
- const message = config.isProduction ?
- 'Internal server error' :
- err.message || 'Internal server error';
-
- res.status(err.status || 500).json({
- success: false,
- error: message
+ res.status(500).json({
+ error: 'Internal server error',
+ message: config.isProduction ? 'An error occurred' : err.message
});
});
-// Start server
-const server = app.listen(config.port, () => {
- logger.info(`
+// Add a simple health check endpoint early in the middleware stack (before other middlewares)
+app.get('/health', (req, res) => {
+ res.status(200).json({
+ status: 'healthy',
+ timestamp: new Date().toISOString(),
+ uptime: process.uptime()
+ });
+});
+
+// Only start server if not already started
+if (!global.__serverStarted) {
+ global.__serverStarted = true;
+
+ const server = app.listen(config.port, () => {
+ logger.info(`
╔════════════════════════════════════════╗
║ BNKops Map Server ║
╠════════════════════════════════════════╣
║ Status: Running ║
║ Port: ${config.port} ║
- ║ Environment: ${config.nodeEnv} ║
+ ║ Environment: ${config.isProduction ? 'production' : 'development'} ║
║ Project ID: ${config.nocodb.projectId} ║
║ Table ID: ${config.nocodb.tableId} ║
- ║ Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'} ║
+ ║ Login Sheet: ${config.nocodb.loginSheetId} ║
║ PID: ${process.pid} ║
║ Time: ${new Date().toISOString()} ║
╚════════════════════════════════════════╝
`);
-});
-
-// Graceful shutdown
-process.on('SIGTERM', () => {
- logger.info('SIGTERM signal received: closing HTTP server');
- server.close(() => {
- logger.info('HTTP server closed');
- process.exit(0);
});
-});
-process.on('SIGINT', () => {
- logger.info('SIGINT signal received: closing HTTP server');
- server.close(() => {
- logger.info('HTTP server closed');
- process.exit(0);
+ // Graceful shutdown
+ process.on('SIGTERM', () => {
+ logger.info('SIGTERM signal received: closing HTTP server');
+ server.close(() => {
+ logger.info('HTTP server closed');
+ process.exit(0);
+ });
});
-});
-// Handle uncaught exceptions
-process.on('uncaughtException', (err) => {
- logger.error('Uncaught exception:', err);
- process.exit(1);
-});
+ process.on('SIGINT', () => {
+ logger.info('SIGINT signal received: closing HTTP server');
+ server.close(() => {
+ logger.info('HTTP server closed');
+ process.exit(0);
+ });
+ });
-// Handle unhandled promise rejections
-process.on('unhandledRejection', (reason, promise) => {
- logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
- process.exit(1);
-});
+ // Handle uncaught exceptions
+ process.on('uncaughtException', (err) => {
+ logger.error('Uncaught Exception:', err);
+ process.exit(1);
+ });
+
+ // Handle unhandled promise rejections
+ process.on('unhandledRejection', (reason, promise) => {
+ logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
+ process.exit(1);
+ });
+}
module.exports = app;
\ No newline at end of file
diff --git a/map/app/utils/logger.js b/map/app/utils/logger.js
index 100313e..aca3772 100644
--- a/map/app/utils/logger.js
+++ b/map/app/utils/logger.js
@@ -1,20 +1,10 @@
const winston = require('winston');
const config = require('../config');
-// Debug: Check if logger is being created multiple times
-const instanceId = Math.random().toString(36).substr(2, 9);
-console.log(`[DEBUG] Creating logger instance ${instanceId} at ${new Date().toISOString()}`);
-
-// Ensure we only create one logger instance
-if (global.appLogger) {
- console.log(`[DEBUG] Reusing existing logger instance`);
- module.exports = global.appLogger;
- return;
-}
-
+// Create the logger only once
const logger = winston.createLogger({
level: config.isProduction ? 'info' : 'debug',
- defaultMeta: { service: 'bnkops-map', instanceId },
+ defaultMeta: { service: 'bnkops-map' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
@@ -50,7 +40,4 @@ if (config.isProduction) {
}));
}
-// Store logger globally to prevent multiple instances
-global.appLogger = logger;
-
module.exports = logger;
\ No newline at end of file