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 const { generateAndUploadQRCode, deleteQRCodeFromNocoDB } = 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 } = req.body; if (!email) { return res.status(400).json({ success: false, error: 'Email is required' }); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ success: false, error: 'Invalid email format' }); } // 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 authorized emails 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: { limit: 1000 // Adjust if you have more authorized users } }); const users = response.data.list || []; // Check if email exists in the authorized users list const authorizedUser = users.find(user => user.Email && user.Email.toLowerCase() === email.toLowerCase() ); if (authorizedUser) { // Set session including admin status req.session.authenticated = true; req.session.userEmail = email; req.session.userName = authorizedUser.Name || email; req.session.isAdmin = authorizedUser.Admin === true || authorizedUser.Admin === 1; // 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 } }); }); } else { logger.warn(`Authentication failed for email: ${email}`); res.status(401).json({ success: false, error: 'Email not authorized. Please contact an administrator.' }); } } 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' }); } // Create a minimal setting record const settingData = { key: 'start_location', title: 'Map Start Location', 'Geo-Location': `${lat};${lng}`, latitude: lat, longitude: lng, zoom: mapZoom, category: 'system_setting' }; const getUrl = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; try { // First, try to find existing setting const searchResponse = await axios.get(getUrl, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' }, params: { where: `(key,eq,start_location)` } }); const existingSettings = searchResponse.data.list || []; if (existingSettings.length > 0) { // Update existing setting const setting = existingSettings[0]; let settingId = setting.id || setting.Id || setting.ID; // If we still can't find an ID, log the object structure if (!settingId) { logger.error('Cannot find primary key in setting object:', { setting: setting, keys: Object.keys(setting) }); throw new Error('Unable to find primary key for existing setting'); } const updateUrl = `${getUrl}/${settingId}`; // Only include fields that exist in the table const updateData = { 'Geo-Location': `${lat};${lng}`, latitude: lat, longitude: lng, zoom: mapZoom }; await axios.patch(updateUrl, updateData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); logger.info(`Admin ${req.session.userEmail} updated start location to: ${lat}, ${lng}, zoom: ${mapZoom}`); } else { // Create new setting await axios.post(getUrl, settingData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); logger.info(`Admin ${req.session.userEmail} created start location: ${lat}, ${lng}, zoom: ${mapZoom}`); } res.json({ success: true, message: 'Start location saved successfully', location: { latitude: lat, longitude: lng, zoom: mapZoom } }); } catch (dbError) { logger.error('Database error saving start location:', { error: dbError.message, response: dbError.response?.data, status: dbError.response?.status }); // Return more detailed error information const errorMessage = dbError.response?.data?.message || dbError.message; throw new Error(`Database error: ${errorMessage}`); } } 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 (admin) 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, 'Content-Type': 'application/json' }, params: { where: `(key,eq,start_location)` } }); const settings = response.data.list || []; if (settings.length > 0) { const setting = settings[0]; return res.json({ success: true, location: { latitude: parseFloat(setting.latitude), longitude: parseFloat(setting.longitude), zoom: parseInt(setting.zoom) || 11 }, source: 'database' }); } } // 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' }); } }); // Get start location for all users (public endpoint) 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, 'Content-Type': 'application/json' }, params: { where: `(key,eq,start_location)` } }); const settings = response.data.list || []; if (settings.length > 0) { const setting = settings[0]; const lat = parseFloat(setting.latitude); const lng = parseFloat(setting.longitude); const zoom = parseInt(setting.zoom) || 11; logger.info(`Start location loaded from database: ${lat}, ${lng}, zoom: ${zoom}`); return res.json({ latitude: lat, longitude: lng, zoom: zoom }); } else { logger.info('No start location found in database, using defaults'); } } 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 app.get('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { try { if (!SETTINGS_SHEET_ID) { return res.json({ success: true, config: null, source: 'defaults' }); } // Get all settings 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 } } ); logger.info('GET Settings response structure:', JSON.stringify(response.data, null, 2)); if (!response.data?.list || response.data.list.length === 0) { return res.json({ success: true, config: null, source: 'defaults' }); } // Find walk sheet settings const walkSheetSettings = {}; const settingKeys = [ 'walk_sheet_title', 'walk_sheet_subtitle', 'walk_sheet_footer', 'qr_code_1_url', 'qr_code_1_label', 'qr_code_1_image', 'qr_code_2_url', 'qr_code_2_label', 'qr_code_2_image', 'qr_code_3_url', 'qr_code_3_label', 'qr_code_3_image' ]; for (const setting of response.data.list) { if (settingKeys.includes(setting.key)) { if (setting.key.includes('_image') && setting.value) { // Parse image data if stored as JSON string try { walkSheetSettings[setting.key] = JSON.parse(setting.value); } catch { walkSheetSettings[setting.key] = setting.value; } } else { walkSheetSettings[setting.key] = setting.value || setting.title || ''; } } } res.json({ success: true, config: walkSheetSettings, source: 'database' }); } catch (error) { logger.error('Failed to get walk sheet config:', error); res.status(500).json({ success: false, error: 'Failed to retrieve walk sheet configuration' }); } }); // Save walk sheet configuration (simplified) app.post('/api/admin/walk-sheet-config', requireAdmin, async (req, res) => { try { if (!SETTINGS_SHEET_ID) { logger.error('SETTINGS_SHEET_ID not configured'); return res.status(400).json({ success: false, error: 'Settings sheet not configured' }); } logger.info('Using SETTINGS_SHEET_ID:', SETTINGS_SHEET_ID); const config = req.body; logger.info('Received config:', JSON.stringify(config, null, 2)); const userEmail = req.session.userEmail; const timestamp = new Date().toISOString(); // Get existing settings const getResponse = 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 } } ); logger.info('Settings response structure:', JSON.stringify(getResponse.data, null, 2)); const existingSettings = getResponse.data?.list || []; // Simple approach: Just save the text configuration (no QR code uploads for now) const simpleSettings = { walk_sheet_title: config.walk_sheet_title || '', walk_sheet_subtitle: config.walk_sheet_subtitle || '', walk_sheet_footer: config.walk_sheet_footer || '', qr_code_1_url: config.qr_code_1_url || '', qr_code_1_label: config.qr_code_1_label || '', qr_code_2_url: config.qr_code_2_url || '', qr_code_2_label: config.qr_code_2_label || '', qr_code_3_url: config.qr_code_3_url || '', qr_code_3_label: config.qr_code_3_label || '' }; // Update or create each setting for (const [key, value] of Object.entries(simpleSettings)) { const existingSetting = existingSettings.find(s => s.key === key); let settingData = { key: key, title: value, value: value, category: 'walk_sheet_setting', updated_by: userEmail, updated_at: timestamp }; if (existingSetting) { // Update existing - use ID from debug output logger.info(`Updating setting ${key} with ID ${existingSetting.ID}`); await axios.patch( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}/${existingSetting.ID}`, settingData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } } ); } else { // Create new logger.info(`Creating new setting ${key}`); await axios.post( `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`, settingData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } } ); } } res.json({ success: true, message: 'Walk sheet configuration saved successfully', savedSettings: simpleSettings }); } catch (error) { logger.error('Failed to save walk sheet config:', error); logger.error('Error response:', error.response?.data); logger.error('Error config:', error.config?.url); res.status(500).json({ success: false, error: 'Failed to save walk sheet configuration. No worries; just hit print, and you can save it there too!', details: error.response?.data || error.message }); } }); // 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, projectId: process.env.NOCODB_PROJECT_ID, tableId: process.env.NOCODB_TABLE_ID, loginSheet: LOGIN_SHEET_ID, loginSheetConfigured: process.env.NOCODB_LOGIN_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 || []; 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 { let updateData = { ...req.body }; // 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}/${req.params.id}`; 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); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to update location' }); } }); // Delete location app.delete('/api/locations/:id', strictLimiter, 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}`; await axios.delete(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN } }); logger.info(`Location ${req.params.id} 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' }); } }); // Debug endpoint to check settings table structure app.get('/api/debug/settings-table', requireAdmin, async (req, res) => { try { logger.info('Debug: SETTINGS_SHEET_ID =', SETTINGS_SHEET_ID); logger.info('Debug: NOCODB_API_URL =', process.env.NOCODB_API_URL); logger.info('Debug: NOCODB_PROJECT_ID =', process.env.NOCODB_PROJECT_ID); if (!SETTINGS_SHEET_ID) { return res.json({ success: false, error: 'SETTINGS_SHEET_ID not configured', settingsSheetId: SETTINGS_SHEET_ID, originalSetting: process.env.NOCODB_SETTINGS_SHEET }); } // Try the working endpoint const workingEndpoint = `/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${SETTINGS_SHEET_ID}`; try { const response = await axios.get( `${process.env.NOCODB_API_URL}${workingEndpoint}`, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN }, params: { limit: 5 } } ); const records = response.data.list || []; const sampleRecord = records.length > 0 ? records[0] : null; res.json({ success: true, settingsSheetId: SETTINGS_SHEET_ID, workingEndpoint: workingEndpoint, recordCount: response.data.pageInfo?.totalRows || 0, sampleRecord: sampleRecord, availableFields: sampleRecord ? Object.keys(sampleRecord) : [], allRecords: records }); } catch (error) { res.json({ success: false, error: error.message, responseData: error.response?.data, status: error.response?.status, settingsSheetId: SETTINGS_SHEET_ID }); } } catch (error) { logger.error('Debug settings table error:', error); res.status(500).json({ success: false, error: error.message, settingsSheetId: SETTINGS_SHEET_ID }); } }); // Simple QR code test endpoint app.get('/api/debug/test-qr', requireAdmin, async (req, res) => { try { const { generateAndUploadQRCode } = require('./services/qrcode'); // Test configuration const testConfig = { apiUrl: process.env.NOCODB_API_URL, apiToken: process.env.NOCODB_API_TOKEN, projectId: process.env.NOCODB_PROJECT_ID, tableId: SETTINGS_SHEET_ID }; // Test QR code generation const testUrl = 'https://example.com/test'; const testLabel = 'Test QR Code'; logger.info('Testing QR code generation...'); const result = await generateAndUploadQRCode(testUrl, testLabel, testConfig); res.json({ success: true, message: 'QR code generated successfully', result: result, testUrl: testUrl, testLabel: testLabel }); } catch (error) { logger.error('QR code test failed:', error); res.status(500).json({ success: false, error: error.message, details: error.response?.data || 'No response data' }); } }); // Local QR code generation endpoint app.get('/api/qr', async (req, res) => { try { const { text, size = 200 } = req.query; if (!text) { return res.status(400).json({ success: false, error: 'Text parameter is required' }); } const { generateQRCode } = require('./services/qrcode'); const qrOptions = { type: 'png', width: parseInt(size), margin: 1, color: { dark: '#000000', light: '#FFFFFF' }, errorCorrectionLevel: 'M' }; const buffer = await generateQRCode(text, qrOptions); res.set({ 'Content-Type': 'image/png', 'Content-Length': buffer.length, 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour }); res.send(buffer); } catch (error) { logger.error('QR code generation error:', error); res.status(500).json({ success: false, error: 'Failed to generate QR code' }); } }); // Simple QR test page app.get('/test-qr', (req, res) => { res.send(`
Try accessing these URLs directly: