const nocodbService = require('../services/nocodb'); const logger = require('../utils/logger'); const config = require('../config'); const spatialUtils = require('../utils/spatial'); class CutsController { /** * Get all cuts - filter by public visibility for non-admins */ async getAll(req, res) { try { // Check if cuts table is configured if (!config.nocodb.cutsSheetId) { // Return empty list if cuts table is not configured return res.json({ list: [] }); } const { isAdmin } = req.user || {}; // For NocoDB v2 API, we need to get all records and filter in memory // since the where clause syntax may be different // Use paginated method to get ALL records (not just the default 25 limit) const response = await nocodbService.getAllPaginated( config.nocodb.cutsSheetId ); // Ensure response has list property if (!response || !response.list) { return res.json({ list: [] }); } // Filter results based on user permissions if (!isAdmin) { response.list = response.list.filter(cut => { const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility']; return isPublic === true || isPublic === 1 || isPublic === '1'; }); } logger.info(`Retrieved ${response.list?.length || 0} cuts for ${isAdmin ? 'admin' : 'user'}`); res.json(response); } catch (error) { logger.error('Error fetching cuts:', error); // Log more details about the error if (error.response) { logger.error('Error response:', error.response.data); logger.error('Error status:', error.response.status); } res.status(500).json({ error: 'Failed to fetch cuts', details: error.message }); } } /** * Get single cut by ID */ async getById(req, res) { try { const { id } = req.params; const { isAdmin } = req.user || {}; const response = await nocodbService.getById( config.nocodb.cutsSheetId, id ); if (!response) { return res.status(404).json({ error: 'Cut not found' }); } // Non-admins can only access public cuts if (!isAdmin) { const isPublic = response.is_public || response.Is_public || response['Public Visibility']; if (!(isPublic === true || isPublic === 1 || isPublic === '1')) { return res.status(403).json({ error: 'Access denied' }); } } logger.info(`Retrieved cut: ${response.name} (ID: ${id})`); res.json(response); } catch (error) { logger.error('Error fetching cut:', error); res.status(500).json({ error: 'Failed to fetch cut', details: error.message }); } } /** * Create new cut - admin only */ async create(req, res) { try { const { isAdmin, email } = req.user || {}; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } const { name, description, color = '#3388ff', opacity = 0.3, category, is_public = false, is_official = false, geojson, bounds, assigned_to } = req.body; // Validate required fields if (!name || !geojson) { return res.status(400).json({ error: 'Name and geojson are required' }); } // Validate GeoJSON try { const parsedGeoJSON = JSON.parse(geojson); if (parsedGeoJSON.type !== 'Polygon' && parsedGeoJSON.type !== 'MultiPolygon') { return res.status(400).json({ error: 'GeoJSON must be a Polygon or MultiPolygon' }); } } catch (parseError) { return res.status(400).json({ error: 'Invalid GeoJSON format' }); } // Validate opacity range if (opacity < 0 || opacity > 1) { return res.status(400).json({ error: 'Opacity must be between 0 and 1' }); } const cutData = { name, description, color, opacity, category, is_public, is_official, geojson, bounds, assigned_to, created_by: email, }; const response = await nocodbService.create( config.nocodb.cutsSheetId, cutData ); logger.info(`Created cut: ${name} by ${email}`); res.status(201).json(response); } catch (error) { logger.error('Error creating cut:', error); res.status(500).json({ error: 'Failed to create cut', details: error.message }); } } /** * Update cut - admin only */ async update(req, res) { try { const { isAdmin, email } = req.user || {}; const { id } = req.params; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } // Check if cut exists const existingCut = await nocodbService.getById( config.nocodb.cutsSheetId, id ); if (!existingCut) { return res.status(404).json({ error: 'Cut not found' }); } const { name, description, color, opacity, category, is_public, is_official, geojson, bounds, assigned_to } = req.body; // Validate GeoJSON if provided if (geojson) { try { const parsedGeoJSON = JSON.parse(geojson); if (parsedGeoJSON.type !== 'Polygon' && parsedGeoJSON.type !== 'MultiPolygon') { return res.status(400).json({ error: 'GeoJSON must be a Polygon or MultiPolygon' }); } } catch (parseError) { return res.status(400).json({ error: 'Invalid GeoJSON format' }); } } // Validate opacity if provided if (opacity !== undefined && (opacity < 0 || opacity > 1)) { return res.status(400).json({ error: 'Opacity must be between 0 and 1' }); } const updateData = { updated_at: new Date().toISOString() }; // Only include fields that are provided if (name !== undefined) updateData.name = name; if (description !== undefined) updateData.description = description; if (color !== undefined) updateData.color = color; if (opacity !== undefined) updateData.opacity = opacity; if (category !== undefined) updateData.category = category; if (is_public !== undefined) updateData.is_public = is_public; if (is_official !== undefined) updateData.is_official = is_official; if (geojson !== undefined) updateData.geojson = geojson; if (bounds !== undefined) updateData.bounds = bounds; if (assigned_to !== undefined) updateData.assigned_to = assigned_to; const response = await nocodbService.update( config.nocodb.cutsSheetId, id, updateData ); logger.info(`Updated cut: ${existingCut.name} (ID: ${id}) by ${email}`); res.json(response); } catch (error) { logger.error('Error updating cut:', error); res.status(500).json({ error: 'Failed to update cut', details: error.message }); } } /** * Delete cut - admin only */ async delete(req, res) { try { const { isAdmin, email } = req.user || {}; const { id } = req.params; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } // Check if cut exists const existingCut = await nocodbService.getById( config.nocodb.cutsSheetId, id ); if (!existingCut) { return res.status(404).json({ error: 'Cut not found' }); } await nocodbService.delete( config.nocodb.cutsSheetId, id ); logger.info(`Deleted cut: ${existingCut.name} (ID: ${id}) by ${email}`); res.json({ message: 'Cut deleted successfully' }); } catch (error) { logger.error('Error deleting cut:', error); res.status(500).json({ error: 'Failed to delete cut', details: error.message }); } } /** * Get public cuts for map display */ async getPublic(req, res) { try { // Check if cuts table is configured if (!config.nocodb.cutsSheetId) { logger.warn('Cuts table not configured - NOCODB_CUTS_SHEET not set'); return res.json({ list: [] }); } logger.info(`Fetching public cuts from table ID: ${config.nocodb.cutsSheetId}`); // Use getAllPaginated to get ALL cuts, not just first page const response = await nocodbService.getAllPaginated( config.nocodb.cutsSheetId ); logger.info(`Raw response from nocodbService.getAll:`, { hasResponse: !!response, hasList: !!(response && response.list), listLength: response?.list?.length || 0, sampleData: response?.list?.[0] || null, allFields: response?.list?.[0] ? Object.keys(response.list[0]) : [] }); // Ensure response has list property if (!response || !response.list) { logger.warn('No cuts found or invalid response structure'); return res.json({ list: [] }); } // Log all cuts before filtering logger.info(`All cuts found: ${response.list.length}`); response.list.forEach((cut, index) => { // Check multiple possible field names for is_public const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility']; logger.info(`Cut ${index}: ${cut.name || cut.Name} - is_public: ${isPublic} (type: ${typeof isPublic})`); logger.info(`Available fields:`, Object.keys(cut)); }); // Filter to only public cuts - handle multiple possible field names const originalCount = response.list.length; response.list = response.list.filter(cut => { const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility']; return isPublic === true || isPublic === 1 || isPublic === '1'; }); const publicCount = response.list.length; logger.info(`Filtered ${originalCount} total cuts to ${publicCount} public cuts`); res.json(response); } catch (error) { logger.error('Error fetching public cuts:', error); // Log more details about the error if (error.response) { logger.error('Error response:', error.response.data); logger.error('Error status:', error.response.status); } res.status(500).json({ error: 'Failed to fetch public cuts', details: error.message }); } } /** * Get all locations within a cut boundary - admin only */ async getLocationsInCut(req, res) { try { const { id } = req.params; const { isAdmin } = req.user || {}; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } // Get the cut const cut = await nocodbService.getById(config.nocodb.cutsSheetId, id); if (!cut) { return res.status(404).json({ error: 'Cut not found' }); } // Get all locations (use paginated to get ALL records, not just first page) const locationsResponse = await nocodbService.getAllPaginated(config.LOCATIONS_TABLE_ID); if (!locationsResponse || !locationsResponse.list) { return res.json({ locations: [], statistics: { total_locations: 0 } }); } // Apply filters from query params const filters = { support_level: req.query.support_level, has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined, sign_size: req.query.sign_size, has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined, has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined }; // Filter locations within cut boundaries const filteredLocations = spatialUtils.filterLocationsInCut( locationsResponse.list, cut, filters ); // Calculate statistics const statistics = spatialUtils.calculateCutStatistics(filteredLocations); const cutName = cut.name || cut.Name || cut.title || cut.Title || 'Unknown'; logger.info(`Found ${filteredLocations.length} locations in cut: ${cutName}`); res.json({ locations: filteredLocations, statistics, cut: { id: cut.id || cut.Id || cut.ID, name: cutName } }); } catch (error) { logger.error('Error getting locations in cut:', error); res.status(500).json({ error: 'Failed to get locations in cut', details: error.message }); } } /** * Export locations within a cut as CSV - admin only */ async exportCutLocations(req, res) { try { const { id } = req.params; const { isAdmin } = req.user || {}; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } // Get the cut const cut = await nocodbService.getById(config.nocodb.cutsSheetId, id); if (!cut) { return res.status(404).json({ error: 'Cut not found' }); } // Check if export is enabled for this cut if (cut.export_enabled === false) { return res.status(403).json({ error: 'Export is disabled for this cut' }); } // Get all locations (use paginated to get ALL records, not just first page) const locationsResponse = await nocodbService.getAllPaginated(config.LOCATIONS_TABLE_ID); if (!locationsResponse || !locationsResponse.list) { return res.json({ locations: [] }); } // Apply filters from query params const filters = { support_level: req.query.support_level, has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined, sign_size: req.query.sign_size, has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined, has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined }; // Filter locations within cut boundaries const filteredLocations = spatialUtils.filterLocationsInCut( locationsResponse.list, cut, filters ); // Generate CSV content const csvHeaders = [ 'ID', 'First Name', 'Last Name', 'Email', 'Phone', 'Address', 'Unit Number', 'Support Level', 'Has Sign', 'Sign Size', 'Latitude', 'Longitude', 'Notes' ]; const csvRows = filteredLocations.map(location => [ location.id || '', location.first_name || '', location.last_name || '', location.email || '', location.phone || '', location.address || '', location.unit_number || '', location.support_level || '', location.sign ? 'Yes' : 'No', location.sign_size || '', location.latitude || '', location.longitude || '', (location.notes || '').replace(/"/g, '""') // Escape quotes in notes ]); const csvContent = [ csvHeaders.join(','), ...csvRows.map(row => row.map(field => `"${field}"`).join(',')) ].join('\n'); const cutName = (cut.name || 'cut').replace(/[^a-zA-Z0-9]/g, '_'); const timestamp = new Date().toISOString().split('T')[0]; const filename = `${cutName}_locations_${timestamp}.csv`; res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(csvContent); logger.info(`Exported ${filteredLocations.length} locations from cut: ${cut.name}`); } catch (error) { logger.error('Error exporting cut locations:', error); res.status(500).json({ error: 'Failed to export cut locations', details: error.message }); } } /** * Update cut settings (visibility, filters, etc.) - admin only */ async updateCutSettings(req, res) { try { const { id } = req.params; const { isAdmin } = req.user || {}; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } const { show_locations, export_enabled, assigned_to, filter_settings, last_canvassed, completion_percentage } = req.body; const updateData = { updated_at: new Date().toISOString() }; // Only include fields that are provided if (show_locations !== undefined) updateData.show_locations = show_locations; if (export_enabled !== undefined) updateData.export_enabled = export_enabled; if (assigned_to !== undefined) updateData.assigned_to = assigned_to; if (filter_settings !== undefined) updateData.filter_settings = JSON.stringify(filter_settings); if (last_canvassed !== undefined) updateData.last_canvassed = last_canvassed; if (completion_percentage !== undefined) updateData.completion_percentage = completion_percentage; const response = await nocodbService.update( config.nocodb.cutsSheetId, id, updateData ); logger.info(`Updated cut settings for cut ID: ${id}`); res.json(response); } catch (error) { logger.error('Error updating cut settings:', error); res.status(500).json({ error: 'Failed to update cut settings', details: error.message }); } } /** * Get cut statistics - admin only */ async getCutStatistics(req, res) { try { const { id } = req.params; const { isAdmin } = req.user || {}; if (!isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } // Get the cut const cut = await nocodbService.getById(config.nocodb.cutsSheetId, id); if (!cut) { return res.status(404).json({ error: 'Cut not found' }); } // Get all locations (use paginated to get ALL records, not just first page) const locationsResponse = await nocodbService.getAllPaginated(config.LOCATIONS_TABLE_ID); if (!locationsResponse || !locationsResponse.list) { return res.json({ statistics: { total_locations: 0 }, cut: { id: cut.id, name: cut.name } }); } // Get locations within cut boundaries (no additional filters) const locationsInCut = spatialUtils.filterLocationsInCut( locationsResponse.list, cut, {} // No additional filters for statistics ); // Calculate statistics const statistics = spatialUtils.calculateCutStatistics(locationsInCut); // Add cut metadata const cutStats = { ...statistics, cut_metadata: { id: cut.id, name: cut.name, category: cut.category, assigned_to: cut.assigned_to, completion_percentage: cut.completion_percentage || 0, last_canvassed: cut.last_canvassed, created_at: cut.created_at } }; logger.info(`Generated statistics for cut: ${cut.name}`); res.json({ statistics: cutStats }); } catch (error) { logger.error('Error getting cut statistics:', error); res.status(500).json({ error: 'Failed to get cut statistics', details: error.message }); } } } module.exports = new CutsController();