636 lines
23 KiB
JavaScript
636 lines
23 KiB
JavaScript
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 the same pattern as getAll method that's known to work
|
|
const response = await nocodbService.getAll(
|
|
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
|
|
const locationsResponse = await nocodbService.getAll(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
|
|
const locationsResponse = await nocodbService.getAll(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
|
|
const locationsResponse = await nocodbService.getAll(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();
|