1861 lines
65 KiB
JavaScript
1861 lines
65 KiB
JavaScript
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 } = 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'
|
|
});
|
|
}
|
|
|
|
// 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(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>QR Code Test</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; padding: 20px; }
|
|
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
|
|
input { padding: 10px; width: 300px; margin-right: 10px; }
|
|
button { padding: 10px 20px; }
|
|
#qr-result { margin-top: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>QR Code Generation Test</h1>
|
|
|
|
<div class="test-section">
|
|
<h3>Test 1: Direct API Call</h3>
|
|
<p>Try accessing these URLs directly:</p>
|
|
<ul>
|
|
<li><a href="/api/qr?text=Hello World" target="_blank">/api/qr?text=Hello World</a></li>
|
|
<li><a href="/api/qr?text=https://example.com&size=200" target="_blank">/api/qr?text=https://example.com&size=200</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="test-section">
|
|
<h3>Test 2: Dynamic Generation</h3>
|
|
<input type="text" id="qr-text" placeholder="Enter text or URL" value="https://map.cmlite.org">
|
|
<button onclick="generateQR()">Generate QR Code</button>
|
|
<div id="qr-result"></div>
|
|
</div>
|
|
|
|
<div class="test-section">
|
|
<h3>Test 3: Using QRCode Library (like admin panel)</h3>
|
|
<input type="text" id="qr-text-canvas" placeholder="Enter text or URL" value="Test QR Code">
|
|
<button onclick="generateQRCanvas()">Generate QR Code (Canvas)</button>
|
|
<div id="qr-canvas-result"></div>
|
|
</div>
|
|
|
|
<script>
|
|
// Test direct image loading
|
|
function generateQR() {
|
|
const text = document.getElementById('qr-text').value;
|
|
const resultDiv = document.getElementById('qr-result');
|
|
|
|
if (!text) {
|
|
resultDiv.innerHTML = '<p style="color: red;">Please enter some text</p>';
|
|
return;
|
|
}
|
|
|
|
const qrUrl = \`/api/qr?text=\${encodeURIComponent(text)}&size=200\`;
|
|
resultDiv.innerHTML = \`
|
|
<p>QR Code for: <strong>\${text}</strong></p>
|
|
<img src="\${qrUrl}" alt="QR Code" style="border: 1px solid #ddd;">
|
|
<p><small>URL: <code>\${qrUrl}</code></small></p>
|
|
\`;
|
|
}
|
|
|
|
// Test using our QRCode library implementation
|
|
function generateQRCanvas() {
|
|
const text = document.getElementById('qr-text-canvas').value;
|
|
const resultDiv = document.getElementById('qr-canvas-result');
|
|
|
|
if (!text) {
|
|
resultDiv.innerHTML = '<p style="color: red;">Please enter some text</p>';
|
|
return;
|
|
}
|
|
|
|
// Create canvas element
|
|
const canvas = document.createElement('canvas');
|
|
resultDiv.innerHTML = \`<p>QR Code for: <strong>\${text}</strong></p>\`;
|
|
resultDiv.appendChild(canvas);
|
|
|
|
// Use our QRCode implementation (same as admin panel)
|
|
window.QRCode = {
|
|
toCanvas: function(canvas, text, options, callback) {
|
|
if (typeof options === 'function') {
|
|
callback = options;
|
|
options = {};
|
|
}
|
|
|
|
const size = options.width || 200;
|
|
const qrUrl = \`/api/qr?text=\${encodeURIComponent(text)}&size=\${size}\`;
|
|
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
|
|
img.onload = function() {
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, size, size);
|
|
|
|
if (callback) callback(null);
|
|
};
|
|
|
|
img.onerror = function() {
|
|
console.error('Failed to load QR code from server');
|
|
|
|
// Fallback: draw a simple placeholder
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, size, size);
|
|
|
|
ctx.fillStyle = '#000000';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('QR Code', size/2, size/2 - 10);
|
|
ctx.fillText('(Failed)', size/2, size/2 + 10);
|
|
|
|
if (callback) callback(new Error('Failed to load QR code'));
|
|
};
|
|
|
|
img.src = qrUrl;
|
|
}
|
|
};
|
|
|
|
QRCode.toCanvas(canvas, text, { width: 200 }, function(error) {
|
|
if (error) {
|
|
console.error('QR generation error:', error);
|
|
resultDiv.innerHTML += '<p style="color: red;">Failed to generate QR code</p>';
|
|
} else {
|
|
console.log('QR code generated successfully');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Auto-generate on page load
|
|
generateQR();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
// 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
|
|
});
|
|
}
|
|
});
|
|
|
|
// 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(`
|
|
╔════════════════════════════════════════╗
|
|
║ NocoDB Map Viewer 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);
|
|
});
|
|
});
|
|
|