General map updates

This commit is contained in:
admin 2025-07-14 09:17:00 -06:00
parent e7269e808f
commit b8439066cc
51 changed files with 12125 additions and 1847 deletions

View File

@ -20,11 +20,11 @@
description: No-code database platform
container: changemakerlite-nocodb-1
server: my-docker
- Gitea:
icon: mdi-git
href: "https://git.lindalindsay.org"
description: Git repository hosting
container: gitea_changemaker
- Map:
icon: mdi-map-marker
href: "https://map.lindalindsay.org"
description: Interactive map of campaign locations
container: nocodb-map-viewer
server: my-docker
- Content & Documentation:
@ -72,3 +72,9 @@
description: Database for NocoDB
container: changemakerlite-root_db-1
server: my-docker
- Gitea:
icon: mdi-git
href: "https://git.lindalindsay.org"
description: Git repository hosting
container: gitea_changemaker
server: my-docker

129
map/ADMIN_IMPLEMENTATION.md Normal file
View File

@ -0,0 +1,129 @@
# Admin Panel Implementation Summary
## Overview
Successfully implemented a complete admin panel with start location management feature for the NocoDB Map Viewer application.
## Files Created/Modified
### Backend Changes
- **server.js**:
- Added `SETTINGS_SHEET_ID` parsing
- Updated login endpoint to include admin status
- Updated auth check endpoint to return admin status
- Added `requireAdmin` middleware
- Added admin routes for start location management
- Added public config endpoint for start location
### Frontend Changes
- **map.js**:
- Added `loadStartLocation()` function
- Updated initialization to load start location first
- Updated `displayUserInfo()` to show admin link for admin users
### New Files Created
- **admin.html**: Admin panel interface with interactive map
- **admin.css**: Styling for the admin panel
- **admin.js**: JavaScript functionality for admin panel
### Configuration
- **.env**: Added `NOCODB_SETTINGS_SHEET` environment variable
- **README.md**: Updated with admin panel documentation
## Database Schema
### Settings Table (New)
Required columns for NocoDB Settings table:
- `key` (Single Line Text): Setting identifier
- `title` (Single Line Text): Display name
- `Geo-Location` (Text): Format "latitude;longitude"
- `latitude` (Decimal): Precision 10, Scale 8
- `longitude` (Decimal): Precision 11, Scale 8
- `zoom` (Number): Map zoom level
- `category` (Single Select): "system_setting"
- `updated_by` (Single Line Text): Last updater email
- `updated_at` (DateTime): Last update time
### Login Table (Existing - Updated)
Ensure the existing login table has:
- `Admin` (Checkbox): Admin privileges column
## Features Implemented
### Admin Authentication
- Admin status determined by `Admin` checkbox in login table
- Session-based authentication with admin flag
- Protected admin routes with `requireAdmin` middleware
- Automatic redirect to login for non-admin users
### Start Location Management
- Interactive map interface for setting coordinates
- Manual coordinate input with validation
- "Use Current Map View" button for easy positioning
- Real-time map updates when coordinates change
- Draggable marker for precise positioning
### Data Persistence
- Start location stored in NocoDB Settings table
- Same geographic data format as main locations table
- Automatic creation/update of settings records
- Audit trail with `updated_by` and `updated_at` fields
### Cascading Fallback System
1. **Database** (highest priority): Admin-configured location
2. **Environment** (medium priority): .env file defaults
3. **Hardcoded** (lowest priority): Edmonton coordinates
### User Experience
- All users automatically see admin-configured start location
- Admin users see ⚙️ Admin button in header
- Seamless navigation between main map and admin panel
- Real-time validation and feedback
## API Endpoints
### Admin Endpoints (require admin auth)
- `GET /admin.html` - Serve admin panel page
- `GET /api/admin/start-location` - Get start location with source info
- `POST /api/admin/start-location` - Save new start location
### Public Endpoints
- `GET /api/config/start-location` - Get start location for all users
## Security Features
- Admin-only access to configuration endpoints
- Input validation for coordinates and zoom levels
- Session-based authentication
- CSRF protection through proper HTTP methods
- HTML escaping to prevent XSS
## Next Steps
1. **Setup Database Tables**:
- Create the Settings table in NocoDB with required columns
- Ensure Login table has Admin checkbox column
2. **Configure Environment**:
- Add `NOCODB_SETTINGS_SHEET` URL to .env file
3. **Test Admin Functionality**:
- Login with admin user
- Access `/admin.html`
- Set start location and verify it appears for all users
4. **Future Enhancements** (ready for implementation):
- Additional admin settings (map themes, marker styles, etc.)
- Bulk location management
- User management interface
- System monitoring dashboard
## Benefits Achieved
**Centralized Control**: Admins can change default map view for all users
**Persistent Storage**: Settings survive server restarts and deployments
**User-Friendly Interface**: Interactive map for easy configuration
**Data Consistency**: Uses same format as main location data
**Security**: Proper authentication and authorization
**Scalability**: Easy to extend with additional admin features
**Reliability**: Multiple fallback options ensure map always loads
The implementation provides a robust foundation for administrative control while maintaining the existing user experience and security standards.

View File

@ -10,6 +10,11 @@ A containerized web application that visualizes geographic data from NocoDB on a
- 🔄 Auto-refresh every 30 seconds
- 📱 Responsive design for mobile devices
- 🔒 Secure API proxy to protect credentials
- 👤 User authentication with login system
- ⚙️ Admin panel for system configuration
- 🎯 Configurable map start location
- 📋 Walk Sheet generator for door-to-door canvassing
- 🔗 QR code integration for digital resources
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
@ -18,64 +23,191 @@ A containerized web application that visualizes geographic data from NocoDB on a
### Prerequisites
- Docker and Docker Compose
- NocoDB instance with a table containing location data
- NocoDB instance with API access
- NocoDB API token
### NocoDB Table Setup
1. Create a table in NocoDB with these required columns:
- `geodata` (Text): Format "latitude;longitude"
- `latitude` (Decimal): Precision 10, Scale 8
- `longitude` (Decimal): Precision 11, Scale 8
2. Optional recommended columns:
- `title` (Text): Location name
- `description` (Long Text): Details
- `category` (Single Select): Classification
### Installation
1. Clone this repository or create the file structure as shown
1. **Get NocoDB API Token**
2. Copy the environment template:
```bash
cp .env.example .env
```
1. Login to your NocoDB instance
2. Click user icon → **Account Settings** → **API Tokens**
3. Create new token with read/write permissions
4. Copy the token for the next step
3. Edit `.env` with your NocoDB details:
2. **Configure Environment**
Edit the `.env` file with your NocoDB API and API Url:
```env
NOCODB_API_URL=https://db.lindalindsay.org/api/v1
NOCODB_API_TOKEN=your-token-here
NOCODB_VIEW_URL=https://db.lindalindsay.org/dashboard/#/nc/p406kno3lbq4zmq/mvtryxrvze6td79
# NocoDB API Configuration
NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
NOCODB_API_TOKEN=your-api-token-here
# These will be populated after running build-nocodb.sh
NOCODB_VIEW_URL=
NOCODB_LOGIN_SHEET=
NOCODB_SETTINGS_SHEET=
# Server Configuration
PORT=3000
NODE_ENV=production
SESSION_SECRET=your-secure-random-string
# Map Defaults (Edmonton, AB)
DEFAULT_LAT=53.5461
DEFAULT_LNG=-113.4938
DEFAULT_ZOOM=11
```
4. Start the application:
3. **Auto-Create Database Structure**
Run the build script to create required tables:
```bash
chmod +x build-nocodb.sh
./build-nocodb.sh
```
This creates three tables:
- **Locations** - Main map data with geo-location, contact info, support levels
- **Login** - User authentication (email, name, admin flag)
- **Settings** - Admin configuration and QR codes
4. **Get Table URLs**
After the script completes:
1. Login to your NocoDB instance
2. Navigate to your project ("Map Viewer Project")
3. Copy the view URLs for each table from your browser address bar
4. URLs should look like: `https://your-nocodb.com/dashboard/#/nc/project-id/table-id`
5. **Update Environment with URLs**
Edit your `.env` file and add the table URLs:
```env
NOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id
NOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id
NOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id
```
6. **Build and Deploy**
Build the Docker image and start the application:
```bash
# Build the Docker image
docker-compose build
# Start the application
docker-compose up -d
```
5. Access the map at: http://localhost:3000
7. **Verify Installation**
## Finding NocoDB IDs
- Check container status: `docker-compose ps`
- View logs: `docker-compose logs -f map-viewer`
- Access the application at: http://localhost:3000
### API Token
1. Click user icon → Account Settings
2. Go to "API Tokens" tab
3. Create new token with read/write permissions
## Database Schema
### Project and Table IDs
- Simply provide the full NocoDB view URL in `NOCODB_VIEW_URL`
- The system will automatically extract the project and table IDs
The build script automatically creates the following table structure:
### Main Locations Table
- `Geo-Location` (Geo-Data): Format "latitude;longitude"
- `latitude` (Decimal): Precision 10, Scale 8
- `longitude` (Decimal): Precision 11, Scale 8
- `First Name` (Single Line Text): Person's first name
- `Last Name` (Single Line Text): Person's last name
- `Email` (Email): Email address
- `Phone` (Single Line Text): Phone number
- `Unit Number` (Single Line Text): Unit or apartment number
- `Support Level` (Single Select): Options: "1", "2", "3", "4" (1=Strong Support/Green, 2=Moderate Support/Yellow, 3=Low Support/Orange, 4=No Support/Red)
- `Address` (Single Line Text): Street address
- `Sign` (Checkbox): Has campaign sign
- `Sign Size` (Single Select): Options: "Small", "Medium", "Large"
- `Notes` (Long Text): Additional details and comments
- `title` (Text): Location name (legacy field)
- `category` (Single Select): Classification (legacy field)
### Login Table
- `Email` (Email): User email address
- `Name` (Single Line Text): User display name
- `Admin` (Checkbox): Admin privileges
### Settings Table
- `key` (Single Line Text): Setting identifier
- `title` (Single Line Text): Display name
- `value` (Long Text): Setting value
- `Geo-Location` (Text): Format "latitude;longitude"
- `latitude` (Decimal): Precision 10, Scale 8
- `longitude` (Decimal): Precision 11, Scale 8
- `zoom` (Number): Map zoom level
- `category` (Single Select): Setting category
- `updated_by` (Single Line Text): Last updater email
- `updated_at` (DateTime): Last update time
- `qr_code_1_image` (Attachment): QR code 1 image
- `qr_code_2_image` (Attachment): QR code 2 image
- `qr_code_3_image` (Attachment): QR code 3 image
## API Endpoints
- `GET /api/locations` - Fetch all locations
- `POST /api/locations` - Create new location
- `GET /api/locations/:id` - Get single location
- `PUT /api/locations/:id` - Update location
- `DELETE /api/locations/:id` - Delete location
### Public Endpoints
- `GET /api/locations` - Fetch all locations (requires auth)
- `POST /api/locations` - Create new location (requires auth)
- `GET /api/locations/:id` - Get single location (requires auth)
- `PUT /api/locations/:id` - Update location (requires auth)
- `DELETE /api/locations/:id` - Delete location (requires auth)
- `GET /api/config/start-location` - Get map start location
- `GET /health` - Health check
### Authentication Endpoints
- `POST /api/auth/login` - User login
- `GET /api/auth/check` - Check authentication status
- `POST /api/auth/logout` - User logout
### Admin Endpoints (requires admin privileges)
- `GET /api/admin/start-location` - Get start location with source info
- `POST /api/admin/start-location` - Update map start location
- `GET /api/admin/walk-sheet-config` - Get walk sheet configuration
- `POST /api/admin/walk-sheet-config` - Save walk sheet configuration
## Admin Panel
Users with admin privileges can access the admin panel at `/admin.html` to configure system settings.
### Features
#### Start Location Configuration
- **Interactive Map**: Visual interface for selecting coordinates
- **Real-time Preview**: See changes immediately on the admin map
- **Validation**: Built-in coordinate and zoom level validation
#### Walk Sheet Generator
- **Printable Forms**: Generate 8.5x11 walk sheets for door-to-door canvassing
- **QR Code Integration**: Add up to 3 QR codes with custom URLs and labels
- **Form Field Matching**: Automatically matches fields from the main location form
- **Live Preview**: See changes as you type
- **Print Optimization**: Proper formatting for printing or PDF export
- **Persistent Storage**: All QR codes and settings saved to NocoDB
- **Real-time Preview**: See changes immediately on the admin map
- **Validation**: Built-in coordinate and zoom level validation
### Access Control
- Admin access is controlled via the `Admin` checkbox in the Login table
- Only authenticated users with admin privileges can access `/admin.html`
- Admin status is checked on every request to admin endpoints
### Start Location Priority
The system uses a cascading fallback system for map start location:
1. **Database**: Admin-configured location stored in Settings table (highest priority)
2. **Environment**: Default values from .env file (medium priority)
3. **Hardcoded**: Edmonton, Canada coordinates (lowest priority)
## Configuration
All configuration is done via environment variables:
@ -85,11 +217,35 @@ All configuration is done via environment variables:
| `NOCODB_API_URL` | NocoDB API base URL | Required |
| `NOCODB_API_TOKEN` | API authentication token | Required |
| `NOCODB_VIEW_URL` | Full NocoDB view URL | Required |
| `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required |
| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Optional |
| `PORT` | Server port | 3000 |
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
## Maintenance Commands
### Update Application
```bash
docker-compose down
git pull origin main
docker-compose build
docker-compose up -d
```
### Development Mode
```bash
cd app
npm install
npm run dev
```
### Health Check
```bash
curl http://localhost:3000/health
```
## Development
To run in development mode:
@ -116,20 +272,29 @@ To run in development mode:
## Troubleshooting
### Locations not showing
- Verify table has `geodata`, `latitude`, and `longitude` columns
- Check that coordinates are valid numbers
- Ensure API token has read permissions
### Cannot add locations
- Verify API token has write permissions
- Check browser console for errors
- Ensure coordinates are within valid ranges
### Connection errors
- Verify NocoDB instance is accessible
- Check API URL format
- Confirm network connectivity
### Build Script Issues
- Ensure NocoDB instance is accessible
- Verify API token has admin permissions
- Check that the NocoDB database is clean (delete all bases before running)
## License
MIT License - See LICENSE file for details
@ -140,3 +305,8 @@ For issues or questions:
1. Check the troubleshooting section
2. Review NocoDB documentation
3. Open an issue on GitHub
## Known Bugs
- First load of page often fails, need to debug
- Want UI for dots to have an edit button that then brings up the form

View File

@ -26,6 +26,10 @@ COPY server.js ./
COPY public ./public
COPY routes ./routes
COPY services ./services
COPY config ./config
COPY controllers ./controllers
COPY middleware ./middleware
COPY utils ./utils
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \

122
map/app/config/index.js Normal file
View File

@ -0,0 +1,122 @@
const path = require('path');
require('dotenv').config();
// Helper function to parse NocoDB URLs
function parseNocoDBUrl(url) {
if (!url) return { projectId: null, tableId: null };
const patterns = [
/#\/nc\/([^\/]+)\/([^\/\?#]+)/,
/\/nc\/([^\/]+)\/([^\/\?#]+)/,
/project\/([^\/]+)\/table\/([^\/\?#]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return {
projectId: match[1],
tableId: match[2]
};
}
}
return { projectId: null, tableId: null };
}
// Auto-parse IDs from URLs
let parsedIds = { projectId: null, tableId: null };
if (process.env.NOCODB_VIEW_URL && (!process.env.NOCODB_PROJECT_ID || !process.env.NOCODB_TABLE_ID)) {
parsedIds = parseNocoDBUrl(process.env.NOCODB_VIEW_URL);
}
// Parse login sheet ID
let loginSheetId = null;
if (process.env.NOCODB_LOGIN_SHEET) {
if (process.env.NOCODB_LOGIN_SHEET.startsWith('http')) {
const { tableId } = parseNocoDBUrl(process.env.NOCODB_LOGIN_SHEET);
loginSheetId = tableId;
} else {
loginSheetId = process.env.NOCODB_LOGIN_SHEET;
}
}
// Parse settings sheet ID
let settingsSheetId = null;
if (process.env.NOCODB_SETTINGS_SHEET) {
if (process.env.NOCODB_SETTINGS_SHEET.startsWith('http')) {
const { tableId } = parseNocoDBUrl(process.env.NOCODB_SETTINGS_SHEET);
settingsSheetId = tableId;
} else {
settingsSheetId = process.env.NOCODB_SETTINGS_SHEET;
}
}
// Parse shifts sheet ID
let shiftsSheetId = null;
if (process.env.NOCODB_SHIFTS_SHEET) {
if (process.env.NOCODB_SHIFTS_SHEET.startsWith('http')) {
const { tableId } = parseNocoDBUrl(process.env.NOCODB_SHIFTS_SHEET);
shiftsSheetId = tableId;
} else {
shiftsSheetId = process.env.NOCODB_SHIFTS_SHEET;
}
}
// Parse shift signups sheet ID
let shiftSignupsSheetId = null;
if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET) {
if (process.env.NOCODB_SHIFT_SIGNUPS_SHEET.startsWith('http')) {
const { tableId } = parseNocoDBUrl(process.env.NOCODB_SHIFT_SIGNUPS_SHEET);
shiftSignupsSheetId = tableId;
} else {
shiftSignupsSheetId = process.env.NOCODB_SHIFT_SIGNUPS_SHEET;
}
}
module.exports = {
// Server config
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
// NocoDB config
nocodb: {
apiUrl: process.env.NOCODB_API_URL,
apiToken: process.env.NOCODB_API_TOKEN,
projectId: process.env.NOCODB_PROJECT_ID || parsedIds.projectId,
tableId: process.env.NOCODB_TABLE_ID || parsedIds.tableId,
loginSheetId,
settingsSheetId,
viewUrl: process.env.NOCODB_VIEW_URL,
shiftsSheetId,
shiftSignupsSheetId
},
// Session config
session: {
secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production',
cookieDomain: process.env.COOKIE_DOMAIN
},
// CORS config
cors: {
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || []
},
// Map defaults
map: {
defaultLat: parseFloat(process.env.DEFAULT_LAT) || 53.5461,
defaultLng: parseFloat(process.env.DEFAULT_LNG) || -113.4938,
defaultZoom: parseInt(process.env.DEFAULT_ZOOM) || 11,
bounds: process.env.BOUND_NORTH ? {
north: parseFloat(process.env.BOUND_NORTH),
south: parseFloat(process.env.BOUND_SOUTH),
east: parseFloat(process.env.BOUND_EAST),
west: parseFloat(process.env.BOUND_WEST)
} : null
},
// Utility functions
parseNocoDBUrl
};

View File

@ -0,0 +1,138 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const { extractId } = require('../utils/helpers');
class AuthController {
async login(req, res) {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
error: 'Invalid email format'
});
}
logger.info('Login attempt:', {
email,
ip: req.ip,
cfIp: req.headers['cf-connecting-ip'],
userAgent: req.headers['user-agent']
});
// Fetch user from NocoDB
const user = await nocodbService.getUserByEmail(email);
if (!user) {
logger.warn(`No user found with email: ${email}`);
return res.status(401).json({
success: false,
error: 'Invalid email or password'
});
}
// Check password
if (user.Password !== password && user.password !== password) {
logger.warn(`Invalid password for email: ${email}`);
return res.status(401).json({
success: false,
error: 'Invalid email or password'
});
}
// Update last login time
try {
const userId = extractId(user);
await nocodbService.update(
require('../config').nocodb.loginSheetId,
userId,
{
'Last Login': new Date().toISOString(),
last_login: new Date().toISOString()
}
);
} catch (updateError) {
logger.warn('Failed to update last login time:', updateError.message);
// Don't fail the login
}
// Set session
req.session.authenticated = true;
req.session.userEmail = email;
req.session.userName = user.Name || user.name || email;
req.session.isAdmin = user.Admin === true || user.Admin === 1 ||
user.admin === true || user.admin === 1;
req.session.userId = extractId(user);
// Force session save
req.session.save((err) => {
if (err) {
logger.error('Session save error:', err);
return res.status(500).json({
success: false,
error: 'Session error. Please try again.'
});
}
logger.info(`User authenticated: ${email}, Admin: ${req.session.isAdmin}`);
res.json({
success: true,
message: 'Login successful',
user: {
email: email,
name: req.session.userName,
isAdmin: req.session.isAdmin
}
});
});
} catch (error) {
logger.error('Login error:', error.message);
res.status(500).json({
success: false,
error: 'Authentication service error. Please try again later.'
});
}
}
async 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'
});
});
}
async 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
});
}
}
module.exports = new AuthController();

View File

@ -0,0 +1,257 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const {
syncGeoFields,
validateCoordinates,
checkBounds,
extractId
} = require('../utils/helpers');
class LocationsController {
async getAll(req, res) {
try {
const { limit = 1000, offset = 0, where } = req.query;
const params = { limit, offset };
if (where) params.where = where;
logger.info('Fetching locations from NocoDB');
const response = await nocodbService.getLocations(params);
const locations = response.list || [];
// Process and validate locations
const validLocations = locations.filter(loc => {
loc = syncGeoFields(loc);
if (loc.latitude && loc.longitude) {
return true;
}
// Try to parse from geodata column
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.pageInfo?.totalRows || validLocations.length,
locations: validLocations
});
} catch (error) {
logger.error('Error fetching locations:', error.message);
if (error.response) {
res.status(error.response.status).json({
success: false,
error: 'Failed to fetch data from NocoDB',
details: error.response.data
});
} else if (error.code === 'ECONNABORTED') {
res.status(504).json({
success: false,
error: 'Request timeout'
});
} else {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
}
async getById(req, res) {
try {
const location = await nocodbService.getById(
config.nocodb.tableId,
req.params.id
);
res.json({
success: true,
location
});
} 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'
});
}
}
async create(req, res) {
try {
let locationData = { ...req.body };
locationData = syncGeoFields(locationData);
const { latitude, longitude, ...additionalData } = locationData;
// Validate coordinates
const validation = validateCoordinates(latitude, longitude);
if (!validation.valid) {
return res.status(400).json({
success: false,
error: validation.error
});
}
// Check bounds if configured
if (config.map.bounds) {
if (!checkBounds(validation.latitude, validation.longitude, config.map.bounds)) {
return res.status(400).json({
success: false,
error: 'Location is outside allowed bounds'
});
}
}
// Format geodata
const geodata = `${validation.latitude};${validation.longitude}`;
// Prepare data for NocoDB
const finalData = {
geodata,
'Geo-Location': geodata,
latitude: validation.latitude,
longitude: validation.longitude,
...additionalData,
created_at: new Date().toISOString(),
created_by: req.session.userEmail
};
logger.info('Creating new location:', {
lat: validation.latitude,
lng: validation.longitude
});
const response = await nocodbService.create(
config.nocodb.tableId,
finalData
);
logger.info('Location created successfully:', extractId(response));
res.status(201).json({
success: true,
location: response
});
} 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'
});
}
}
}
async update(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 fields to avoid conflicts
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;
logger.info(`Updating location ${locationId} by ${req.session.userEmail}`);
const response = await nocodbService.update(
config.nocodb.tableId,
locationId,
updateData
);
res.json({
success: true,
location: response
});
} 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',
details: error.response?.data?.message || error.message
});
}
}
async delete(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'
});
}
await nocodbService.delete(
config.nocodb.tableId,
locationId
);
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'
});
}
}
}
module.exports = new LocationsController();

View File

@ -0,0 +1,370 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const { validateUrl, extractId, extractWalkSheetConfig } = require('../utils/helpers');
class SettingsController {
// Default settings values
static defaultSettings = {
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: ''
};
async getStartLocation(req, res) {
try {
const settings = await nocodbService.getLatestSettings();
if (settings) {
let lat, lng, zoom;
if (settings['Geo-Location']) {
const parts = settings['Geo-Location'].split(';');
if (parts.length === 2) {
lat = parseFloat(parts[0]);
lng = parseFloat(parts[1]);
}
} else if (settings.latitude && settings.longitude) {
lat = parseFloat(settings.latitude);
lng = parseFloat(settings.longitude);
}
zoom = parseInt(settings.zoom) || config.map.defaultZoom;
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
return res.json({
success: true,
location: {
latitude: lat,
longitude: lng,
zoom: zoom
},
source: 'database',
settingsId: extractId(settings),
lastUpdated: settings.created_at
});
}
}
// Return defaults
res.json({
success: true,
location: {
latitude: config.map.defaultLat,
longitude: config.map.defaultLng,
zoom: config.map.defaultZoom
},
source: 'defaults'
});
} catch (error) {
logger.error('Error fetching start location:', error);
// Return defaults on error
res.json({
success: true,
location: {
latitude: config.map.defaultLat,
longitude: config.map.defaultLng,
zoom: config.map.defaultZoom
},
source: 'defaults'
});
}
}
async updateStartLocation(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 (!config.nocodb.settingsSheetId) {
return res.status(500).json({
success: false,
error: 'Settings sheet not configured'
});
}
// Get current settings to preserve other fields
let currentConfig = {};
try {
currentConfig = await nocodbService.getLatestSettings() || {};
// Debug logging to see what we're getting
logger.info('Retrieved current config:', {
id: currentConfig.Id || currentConfig.ID || currentConfig.id,
walk_sheet_title: currentConfig.walk_sheet_title,
walk_sheet_subtitle: currentConfig.walk_sheet_subtitle,
walk_sheet_footer: currentConfig.walk_sheet_footer,
hasFooter: !!currentConfig.walk_sheet_footer,
footerType: typeof currentConfig.walk_sheet_footer,
allKeys: Object.keys(currentConfig)
});
} catch (error) {
logger.warn('Could not retrieve current settings for preservation, using defaults:', error.message);
currentConfig = {};
}
// Create new settings row - use values directly without || operator
const walkSheetConfig = extractWalkSheetConfig(currentConfig, SettingsController.defaultSettings);
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 walk sheet fields using helper function
...walkSheetConfig
};
logger.info('Creating settings row with data:', {
walk_sheet_footer: settingData.walk_sheet_footer,
footerLength: settingData.walk_sheet_footer?.length
});
const response = await nocodbService.create(
config.nocodb.settingsSheetId,
settingData
);
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: extractId(response)
});
} catch (error) {
logger.error('Error updating start location:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to update start location'
});
}
}
async getWalkSheetConfig(req, res) {
try {
if (!config.nocodb.settingsSheetId) {
logger.warn('SETTINGS_SHEET_ID not configured, returning defaults');
return res.json({
success: true,
config: SettingsController.defaultSettings,
source: 'defaults',
message: 'Settings sheet not configured, using defaults'
});
}
const settings = await nocodbService.getLatestSettings();
if (!settings) {
logger.info('No settings found in database, returning defaults');
return res.json({
success: true,
config: SettingsController.defaultSettings,
source: 'defaults',
message: 'No settings found in database'
});
}
const walkSheetConfig = extractWalkSheetConfig(settings, SettingsController.defaultSettings);
logger.info(`Retrieved walk sheet config from database (ID: ${extractId(settings)})`);
res.json({
success: true,
config: walkSheetConfig,
source: 'database',
settingsId: extractId(settings),
lastUpdated: settings.created_at || settings.updated_at
});
} catch (error) {
logger.error('Failed to get walk sheet config:', error);
// Return defaults if there's an error
res.json({
success: true,
config: SettingsController.defaultSettings,
source: 'defaults',
message: 'Error retrieving from database, using defaults',
error: error.message
});
}
}
async updateWalkSheetConfig(req, res) {
try {
if (!config.nocodb.settingsSheetId) {
return res.status(500).json({
success: false,
error: 'Settings sheet not configured'
});
}
const configData = req.body;
logger.info('Received walk sheet config:', JSON.stringify(configData, null, 2));
// Validate input
if (!configData || typeof configData !== 'object') {
return res.status(400).json({
success: false,
error: 'Invalid configuration data'
});
}
// Get current settings to preserve other fields
let currentConfig = {};
try {
currentConfig = await nocodbService.getLatestSettings() || {};
} catch (error) {
logger.warn('Could not retrieve current settings for preservation, using defaults:', error.message);
currentConfig = {};
}
const userEmail = req.session.userEmail;
const timestamp = new Date().toISOString();
// Prepare data for saving
const walkSheetData = {
created_at: timestamp,
created_by: userEmail,
// Preserve map location fields with consistent fallbacks
'Geo-Location': currentConfig['Geo-Location'] || currentConfig.geodata || `${config.map.defaultLat};${config.map.defaultLng}`,
latitude: currentConfig.latitude || config.map.defaultLat,
longitude: currentConfig.longitude || config.map.defaultLng,
zoom: currentConfig.zoom || config.map.defaultZoom,
// Walk sheet fields (what we're updating)
walk_sheet_title: (configData.walk_sheet_title || '').toString().trim(),
walk_sheet_subtitle: (configData.walk_sheet_subtitle || '').toString().trim(),
walk_sheet_footer: (configData.walk_sheet_footer || '').toString().trim(),
'Walk Sheet Title': (configData.walk_sheet_title || '').toString().trim(),
'Walk Sheet Subtitle': (configData.walk_sheet_subtitle || '').toString().trim(),
'Walk Sheet Footer': (configData.walk_sheet_footer || '').toString().trim(),
qr_code_1_url: validateUrl(configData.qr_code_1_url),
qr_code_1_label: (configData.qr_code_1_label || '').toString().trim(),
qr_code_2_url: validateUrl(configData.qr_code_2_url),
qr_code_2_label: (configData.qr_code_2_label || '').toString().trim(),
qr_code_3_url: validateUrl(configData.qr_code_3_url),
qr_code_3_label: (configData.qr_code_3_label || '').toString().trim(),
'QR Code 1 URL': validateUrl(configData.qr_code_1_url),
'QR Code 1 Label': (configData.qr_code_1_label || '').toString().trim(),
'QR Code 2 URL': validateUrl(configData.qr_code_2_url),
'QR Code 2 Label': (configData.qr_code_2_label || '').toString().trim(),
'QR Code 3 URL': validateUrl(configData.qr_code_3_url),
'QR Code 3 Label': (configData.qr_code_3_label || '').toString().trim()
};
const response = await nocodbService.create(
config.nocodb.settingsSheetId,
walkSheetData
);
const newId = extractId(response);
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);
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()
});
}
}
// Public endpoint for start location (no auth required)
async getPublicStartLocation(req, res) {
try {
const settings = await nocodbService.getLatestSettings();
if (settings) {
let lat, lng, zoom;
if (settings['Geo-Location']) {
const parts = settings['Geo-Location'].split(';');
if (parts.length === 2) {
lat = parseFloat(parts[0]);
lng = parseFloat(parts[1]);
}
} else if (settings.latitude && settings.longitude) {
lat = parseFloat(settings.latitude);
lng = parseFloat(settings.longitude);
}
zoom = parseInt(settings.zoom) || config.map.defaultZoom;
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
});
}
}
} catch (error) {
logger.error('Error fetching config start location:', error);
}
// Return defaults
logger.info(`Using default start location: ${config.map.defaultLat}, ${config.map.defaultLng}, zoom: ${config.map.defaultZoom}`);
res.json({
latitude: config.map.defaultLat,
longitude: config.map.defaultLng,
zoom: config.map.defaultZoom
});
}
}
module.exports = new SettingsController();

View File

@ -0,0 +1,461 @@
const nocodbService = require('../services/nocodb');
const config = require('../config');
const logger = require('../utils/logger');
const { extractId } = require('../utils/helpers');
class ShiftsController {
// Get all shifts (public)
async getAll(req, res) {
try {
if (!config.nocodb.shiftsSheetId) {
return res.status(500).json({
success: false,
error: 'Shifts not configured'
});
}
logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId);
// Load all shifts without filter - we'll filter in JavaScript
const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, {
sort: 'Date,Start Time'
});
logger.info('Loaded shifts:', response);
// Filter out cancelled shifts manually
const shifts = (response.list || []).filter(shift =>
shift.Status !== 'Cancelled'
);
res.json({
success: true,
shifts: shifts
});
} catch (error) {
logger.error('Error fetching shifts:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch shifts'
});
}
}
// Get user's signups
async getUserSignups(req, res) {
try {
const userEmail = req.session.userEmail;
// Check if shift signups sheet is configured
if (!config.nocodb.shiftSignupsSheetId) {
logger.warn('Shift signups sheet not configured');
return res.json({
success: true,
signups: []
});
}
// Load all signups and filter in JavaScript
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId, {
sort: '-Signup Date'
});
logger.info('All signups loaded:', allSignups);
logger.info('Filtering for user:', userEmail);
// Filter for this user's confirmed signups
const userSignups = (allSignups.list || []).filter(signup => {
logger.debug('Checking signup:', signup);
// NocoDB returns fields with title case
const email = signup['User Email'];
const status = signup.Status;
logger.debug(`Comparing: email="${email}" vs userEmail="${userEmail}", status="${status}"`);
return email === userEmail && status === 'Confirmed';
});
logger.info('User signups found:', userSignups);
// Transform to match expected format in frontend
const transformedSignups = userSignups.map(signup => ({
id: signup.ID || signup.id,
shift_id: signup['Shift ID'],
user_email: signup['User Email'],
user_name: signup['User Name'],
signup_date: signup['Signup Date'],
status: signup.Status
}));
res.json({
success: true,
signups: transformedSignups
});
} catch (error) {
logger.error('Error fetching user signups:', error);
// Don't fail, just return empty array
res.json({
success: true,
signups: []
});
}
}
// Sign up for a shift
async signup(req, res) {
try {
if (!config.nocodb.shiftsSheetId) {
return res.status(400).json({
success: false,
error: 'Shifts sheet not configured'
});
}
if (!config.nocodb.shiftSignupsSheetId) {
return res.status(400).json({
success: false,
error: 'Shift signups sheet not configured'
});
}
const { shiftId } = req.params;
const userEmail = req.session.userEmail;
const userName = req.session.userName || userEmail;
logger.info(`User ${userEmail} attempting to sign up for shift ${shiftId}`);
// Check if shift exists and is open
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
if (!shift || shift.Status === 'Cancelled') {
return res.status(400).json({
success: false,
error: 'Shift not available'
});
}
if (shift['Current Volunteers'] >= shift['Max Volunteers']) {
return res.status(400).json({
success: false,
error: 'Shift is full'
});
}
// Check if already signed up - get all signups and filter
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const existingSignup = (allSignups.list || []).find(signup => {
return signup['Shift ID'] === parseInt(shiftId) &&
signup['User Email'] === userEmail &&
signup.Status === 'Confirmed';
});
if (existingSignup) {
return res.status(400).json({
success: false,
error: 'Already signed up for this shift'
});
}
// Create signup
const signup = await nocodbService.create(config.nocodb.shiftSignupsSheetId, {
'Shift ID': parseInt(shiftId),
'User Email': userEmail,
'User Name': userName,
'Signup Date': new Date().toISOString(),
'Status': 'Confirmed'
});
logger.info('Created signup:', signup);
// Update shift volunteer count
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
'Current Volunteers': (shift['Current Volunteers'] || 0) + 1,
'Status': shift['Current Volunteers'] + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open'
});
res.json({
success: true,
message: 'Successfully signed up for shift'
});
} catch (error) {
logger.error('Error signing up for shift:', error);
res.status(500).json({
success: false,
error: 'Failed to sign up for shift'
});
}
}
// Cancel shift signup
async cancelSignup(req, res) {
try {
if (!config.nocodb.shiftsSheetId || !config.nocodb.shiftSignupsSheetId) {
return res.status(400).json({
success: false,
error: 'Shifts not configured'
});
}
const { shiftId } = req.params;
const userEmail = req.session.userEmail;
logger.info(`User ${userEmail} attempting to cancel signup for shift ${shiftId}`);
// Find the signup
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const signup = (allSignups.list || []).find(s => {
return s['Shift ID'] === parseInt(shiftId) &&
s['User Email'] === userEmail &&
s.Status === 'Confirmed';
});
if (!signup) {
return res.status(404).json({
success: false,
error: 'Signup not found'
});
}
// Update signup status to cancelled
await nocodbService.update(config.nocodb.shiftSignupsSheetId, signup.ID || signup.id, {
'Status': 'Cancelled'
});
// Update shift volunteer count
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
const newCount = Math.max(0, (shift['Current Volunteers'] || 0) - 1);
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
'Current Volunteers': newCount,
'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open'
});
res.json({
success: true,
message: 'Successfully cancelled signup'
});
} catch (error) {
logger.error('Error cancelling signup:', error);
res.status(500).json({
success: false,
error: 'Failed to cancel signup'
});
}
}
// Admin: Create shift
async create(req, res) {
try {
const { title, description, date, startTime, endTime, location, maxVolunteers } = req.body;
if (!title || !date || !startTime || !endTime || !location || !maxVolunteers) {
return res.status(400).json({
success: false,
error: 'Missing required fields'
});
}
const shift = await nocodbService.create(config.nocodb.shiftsSheetId, {
Title: title,
Description: description,
Date: date,
'Start Time': startTime,
'End Time': endTime,
Location: location,
'Max Volunteers': parseInt(maxVolunteers),
'Current Volunteers': 0,
Status: 'Open',
'Created By': req.session.userEmail,
'Created At': new Date().toISOString(),
'Updated At': new Date().toISOString()
});
res.json({
success: true,
shift
});
} catch (error) {
logger.error('Error creating shift:', error);
res.status(500).json({
success: false,
error: 'Failed to create shift'
});
}
}
// Admin: Update shift
async update(req, res) {
try {
const { id } = req.params;
const updateData = {};
// Map fields that can be updated
const fieldMap = {
title: 'Title',
description: 'Description',
date: 'Date',
startTime: 'Start Time',
endTime: 'End Time',
location: 'Location',
maxVolunteers: 'Max Volunteers',
status: 'Status'
};
for (const [key, field] of Object.entries(fieldMap)) {
if (req.body[key] !== undefined) {
updateData[field] = req.body[key];
}
}
if (updateData['Max Volunteers']) {
updateData['Max Volunteers'] = parseInt(updateData['Max Volunteers']);
}
updateData['Updated At'] = new Date().toISOString();
const updated = await nocodbService.update(config.nocodb.shiftsSheetId, id, updateData);
res.json({
success: true,
shift: updated
});
} catch (error) {
logger.error('Error updating shift:', error);
res.status(500).json({
success: false,
error: 'Failed to update shift'
});
}
}
// Admin: Delete shift
async delete(req, res) {
try {
const { id } = req.params;
// Check if signups sheet is configured
if (config.nocodb.shiftSignupsSheetId) {
try {
// Get all signups and filter in JavaScript to avoid NocoDB query issues
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
// Filter for confirmed signups for this shift
const signupsToCancel = (allSignups.list || []).filter(signup =>
signup['Shift ID'] === parseInt(id) && signup.Status === 'Confirmed'
);
// Cancel each signup
for (const signup of signupsToCancel) {
await nocodbService.update(config.nocodb.shiftSignupsSheetId, signup.ID || signup.id, {
Status: 'Cancelled'
});
}
logger.info(`Cancelled ${signupsToCancel.length} signups for shift ${id}`);
} catch (signupError) {
logger.error('Error cancelling signups:', signupError);
// Continue with shift deletion even if signup cancellation fails
}
}
// Delete the shift
await nocodbService.delete(config.nocodb.shiftsSheetId, id);
res.json({
success: true,
message: 'Shift deleted successfully'
});
} catch (error) {
logger.error('Error deleting shift:', error);
res.status(500).json({
success: false,
error: 'Failed to delete shift'
});
}
}
// Admin: Get all shifts with signup details
async getAllAdmin(req, res) {
try {
if (!config.nocodb.shiftsSheetId) {
logger.error('Shifts sheet not configured');
return res.status(500).json({
success: false,
error: 'Shifts not configured'
});
}
logger.info('Loading admin shifts from:', config.nocodb.shiftsSheetId);
let shifts;
try {
shifts = await nocodbService.getAll(config.nocodb.shiftsSheetId, {
sort: '-Date,-Start Time'
});
} catch (apiError) {
logger.error('Error loading shifts from NocoDB:', apiError);
// If it's a 422 error, try without sort parameters
if (apiError.response?.status === 422) {
logger.warn('Retrying without sort parameters due to 422 error');
try {
shifts = await nocodbService.getAll(config.nocodb.shiftsSheetId);
} catch (retryError) {
logger.error('Retry also failed:', retryError);
return res.status(500).json({
success: false,
error: 'Failed to load shifts from database'
});
}
} else {
throw apiError;
}
}
logger.info('Loaded shifts:', shifts);
// Only try to get signups if the signups sheet is configured
if (config.nocodb.shiftSignupsSheetId) {
// Get signup counts for each shift
for (const shift of shifts.list || []) {
try {
const signups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
// Filter signups for this shift manually
const shiftSignups = (signups.list || []).filter(signup =>
signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed'
);
shift.signups = shiftSignups;
} catch (signupError) {
logger.error(`Error loading signups for shift ${shift.ID}:`, signupError);
shift.signups = [];
}
}
} else {
logger.warn('Shift signups sheet not configured, skipping signup data');
// Set empty signups for all shifts
for (const shift of shifts.list || []) {
shift.signups = [];
}
}
res.json({
success: true,
shifts: shifts.list || []
});
} catch (error) {
logger.error('Error fetching admin shifts:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch shifts'
});
}
}
}
module.exports = new ShiftsController();

View File

@ -0,0 +1,146 @@
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
const config = require('../config');
const { sanitizeUser, extractId } = require('../utils/helpers');
class UsersController {
async getAll(req, res) {
try {
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
const response = await nocodbService.getAll(config.nocodb.loginSheetId, {
limit: 100,
sort: '-created_at'
});
const users = response.list || [];
// Remove password field from response for security
const safeUsers = users.map(sanitizeUser);
res.json({
success: true,
users: safeUsers
});
} catch (error) {
logger.error('Error fetching users:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch users'
});
}
}
async create(req, res) {
try {
const { email, password, name, admin } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Check if user already exists
const existingUser = await nocodbService.getUserByEmail(email);
if (existingUser) {
return res.status(400).json({
success: false,
error: 'User with this email already exists'
});
}
// Create new user
const userData = {
Email: email,
email: email,
Password: password,
password: password,
Name: name || '',
name: name || '',
Admin: admin === true,
admin: admin === true,
'Created At': new Date().toISOString(),
created_at: new Date().toISOString()
};
const response = await nocodbService.create(
config.nocodb.loginSheetId,
userData
);
res.status(201).json({
success: true,
message: 'User created successfully',
user: {
id: extractId(response),
email: email,
name: name,
admin: admin
}
});
} catch (error) {
logger.error('Error creating user:', error);
res.status(500).json({
success: false,
error: 'Failed to create user'
});
}
}
async delete(req, res) {
try {
const userId = req.params.id;
if (!config.nocodb.loginSheetId) {
return res.status(500).json({
success: false,
error: 'Login sheet not configured'
});
}
// Don't allow admins to delete themselves
if (userId === req.session.userId) {
return res.status(400).json({
success: false,
error: 'Cannot delete your own account'
});
}
await nocodbService.delete(
config.nocodb.loginSheetId,
userId
);
res.json({
success: true,
message: 'User deleted successfully'
});
} catch (error) {
logger.error('Error deleting user:', error);
res.status(500).json({
success: false,
error: 'Failed to delete user'
});
}
}
}
module.exports = new UsersController();

View File

@ -0,0 +1,34 @@
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');
}
}
};
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');
}
}
};
module.exports = {
requireAuth,
requireAdmin
};

View File

@ -0,0 +1,44 @@
const rateLimit = require('express-rate-limit');
const config = require('../config');
// Helper to extract real IP with Cloudflare support
const keyGenerator = (req) => {
return req.headers['cf-connecting-ip'] ||
req.headers['x-forwarded-for']?.split(',')[0] ||
req.ip;
};
// General API rate limiter
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
keyGenerator,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many requests, please try again later.'
});
// Strict limiter for write operations
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
keyGenerator,
message: 'Too many write operations, please try again later.'
});
// Auth-specific limiter
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: config.isProduction ? 10 : 50,
keyGenerator,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many login attempts, please try again later.',
skipSuccessfulRequests: true
});
module.exports = {
apiLimiter,
strictLimiter,
authLimiter
};

View File

@ -16,7 +16,10 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.4",
"express-session": "^1.18.1",
"form-data": "^4.0.0",
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"qrcode": "^1.5.3",
"winston": "^3.11.0"
},
"devDependencies": {
@ -65,6 +68,44 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansi-styles/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/ansi-styles/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@ -79,6 +120,11 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -176,6 +222,22 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -214,6 +276,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -239,6 +309,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
@ -303,6 +383,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/concat-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/concat-stream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -361,6 +482,11 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@ -383,6 +509,14 @@
"ms": "2.0.0"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -411,6 +545,11 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
@ -443,6 +582,11 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/enabled": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
@ -650,6 +794,18 @@
"node": ">= 0.8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fn.name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
@ -734,6 +890,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -933,6 +1097,14 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -968,12 +1140,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/logform": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@ -1079,12 +1267,49 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -1209,6 +1434,39 @@
"fn.name": "1.x.x"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1218,6 +1476,14 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@ -1237,6 +1503,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -1263,6 +1542,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@ -1338,6 +1633,19 @@
"node": ">=8.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1440,6 +1748,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -1558,6 +1871,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -1567,6 +1888,30 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -1640,6 +1985,11 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@ -1692,6 +2042,11 @@
"node": ">= 0.8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/winston": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
@ -1727,6 +2082,65 @@
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

View File

@ -25,7 +25,10 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.4",
"express-session": "^1.18.1",
"form-data": "^4.0.0",
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"qrcode": "^1.5.3",
"winston": "^3.11.0"
},
"devDependencies": {

310
map/app/public/admin.html Normal file
View File

@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Admin Panel - BNKops Map - Interactive canvassing web-app & viewer">
<title>Admin Panel</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="shortcut icon" href="/favicon.ico">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<h1>Admin Panel</h1>
<div class="header-actions">
<a href="/" class="btn btn-secondary">← Back to Map</a>
<span id="admin-info" class="admin-info"></span>
</div>
</header>
<!-- Main Content -->
<div class="admin-container">
<div class="admin-sidebar">
<h2>Admin Panel</h2>
<nav class="admin-nav">
<a href="#start-location" class="active">Start Location</a>
<a href="#walk-sheet">Walk Sheet</a>
<a href="#shifts">Shifts</a>
</nav>
</div>
<div class="admin-content">
<!-- Start Location Section -->
<section id="start-location" class="admin-section">
<h2>Map Start Location</h2>
<p>Set the default center point and zoom level for the map when users first load the application.</p>
<div class="admin-map-container">
<div id="admin-map" class="admin-map"></div>
<div class="location-controls">
<div class="form-group">
<label for="start-lat">Latitude</label>
<input type="number" id="start-lat" step="0.000001" min="-90" max="90">
</div>
<div class="form-group">
<label for="start-lng">Longitude</label>
<input type="number" id="start-lng" step="0.000001" min="-180" max="180">
</div>
<div class="form-group">
<label for="start-zoom">Zoom Level</label>
<input type="number" id="start-zoom" min="2" max="19" step="1">
</div>
<div class="form-actions">
<button id="use-current-view" class="btn btn-secondary">
Use Current Map View
</button>
<button id="save-start-location" class="btn btn-primary">
Save Start Location
</button>
</div>
<div class="help-text">
<p>💡 Tip: Navigate the map to your desired location and zoom level, then click "Use Current Map View" to capture the coordinates.</p>
</div>
</div>
</div>
</section>
<!-- Walk Sheet Section -->
<section id="walk-sheet" class="admin-section" style="display: none;">
<h2>Walk Sheet Configuration</h2>
<p>Design and configure printable walk sheets for door-to-door canvassing.</p>
<div class="walk-sheet-container">
<div class="walk-sheet-config">
<h3>Sheet Information</h3>
<div class="form-group">
<label for="walk-sheet-title">Sheet Title</label>
<input type="text" id="walk-sheet-title" placeholder="Campaign Walk Sheet">
</div>
<div class="form-group">
<label for="walk-sheet-subtitle">Subtitle</label>
<input type="text" id="walk-sheet-subtitle" placeholder="Door-to-Door Canvassing Form">
</div>
<div class="form-group">
<label for="walk-sheet-footer">Footer Text</label>
<textarea id="walk-sheet-footer" rows="3" placeholder="Contact info, legal text, etc."></textarea>
</div>
<h3>QR Codes</h3>
<p class="help-text-inline">Add up to 3 QR codes for quick access to digital resources.</p>
<!-- QR Code 1 -->
<div class="qr-code-group">
<h4>QR Code 1</h4>
<div class="form-row">
<div class="form-group">
<label for="qr-code-1-url">URL</label>
<input type="url" id="qr-code-1-url" placeholder="https://example.com/signup">
</div>
<div class="form-group">
<label for="qr-code-1-label">Label</label>
<input type="text" id="qr-code-1-label" placeholder="Sign Up">
</div>
</div>
</div>
<!-- QR Code 2 -->
<div class="qr-code-group">
<h4>QR Code 2</h4>
<div class="form-row">
<div class="form-group">
<label for="qr-code-2-url">URL</label>
<input type="url" id="qr-code-2-url" placeholder="https://example.com/donate">
</div>
<div class="form-group">
<label for="qr-code-2-label">Label</label>
<input type="text" id="qr-code-2-label" placeholder="Donate">
</div>
</div>
</div>
<!-- QR Code 3 -->
<div class="qr-code-group">
<h4>QR Code 3</h4>
<div class="form-row">
<div class="form-group">
<label for="qr-code-3-url">URL</label>
<input type="url" id="qr-code-3-url" placeholder="https://example.com/volunteer">
</div>
<div class="form-group">
<label for="qr-code-3-label">Label</label>
<input type="text" id="qr-code-3-label" placeholder="Volunteer">
</div>
</div>
</div>
<div class="form-actions">
<button id="save-walk-sheet" class="btn btn-primary">
Save Configuration
</button>
<button id="print-walk-sheet" class="btn btn-secondary">
🖨️ Print Sheet
</button>
</div>
</div>
<div class="walk-sheet-preview">
<h3>Preview</h3>
<div class="preview-controls">
<span class="preview-info">8.5" x 11" format</span>
</div>
<div id="walk-sheet-preview-content" class="walk-sheet-page">
<!-- Preview content will be generated here -->
</div>
</div>
</div>
</section>
<!-- Shifts Section -->
<section id="shifts" class="admin-section" style="display: none;">
<h2>Shift Management</h2>
<p>Create and manage volunteer shifts.</p>
<div class="shifts-admin-container">
<div class="shift-form">
<h3>Create New Shift</h3>
<form id="shift-form">
<div class="form-group">
<label for="shift-title">Title</label>
<input type="text" id="shift-title" required>
</div>
<div class="form-group">
<label for="shift-description">Description</label>
<textarea id="shift-description" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="shift-date">Date</label>
<input type="date" id="shift-date" required>
</div>
<div class="form-group">
<label for="shift-start">Start Time</label>
<input type="time" id="shift-start" required>
</div>
<div class="form-group">
<label for="shift-end">End Time</label>
<input type="time" id="shift-end" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="shift-location">Location</label>
<input type="text" id="shift-location">
</div>
<div class="form-group">
<label for="shift-max-volunteers">Max Volunteers</label>
<input type="number" id="shift-max-volunteers" min="1" required>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Shift</button>
<button type="button" class="btn btn-secondary" id="clear-shift-form">Clear</button>
</div>
</form>
</div>
<div class="shifts-list">
<h3>Existing Shifts</h3>
<div id="admin-shifts-list">
<!-- Shifts will be loaded here -->
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Status Messages -->
<div id="status-container" class="status-container"></div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Custom QR Code Implementation -->
<script>
// Simple QR Code implementation using our server
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;
}
};
console.log('Local QR Code implementation loaded');
</script>
<!-- Admin JavaScript -->
<script src="js/admin.js"></script>
</body>
</html>

View File

@ -0,0 +1,815 @@
/* Admin Panel Specific Styles */
.admin-container {
display: flex;
height: calc(100vh - var(--header-height));
background-color: #f5f5f5;
}
.admin-sidebar {
width: 250px;
background-color: white;
border-right: 1px solid #e0e0e0;
padding: 20px;
}
.admin-sidebar h2 {
font-size: 18px;
margin-bottom: 20px;
color: var(--dark-color);
}
.admin-nav {
display: flex;
flex-direction: column;
gap: 5px;
}
.admin-nav a {
padding: 10px 15px;
color: var(--dark-color);
text-decoration: none;
border-radius: var(--border-radius);
transition: var(--transition);
}
.admin-nav a:hover {
background-color: var(--light-color);
}
.admin-nav a.active {
background-color: var(--primary-color);
color: white;
}
.admin-content {
flex: 1;
padding: 30px;
overflow-y: auto;
}
.admin-section {
background-color: white;
border-radius: var(--border-radius);
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.admin-section h2 {
margin-bottom: 15px;
color: var(--dark-color);
}
.admin-section p {
color: #666;
margin-bottom: 25px;
}
.admin-map-container {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
margin-top: 20px;
}
.admin-map {
height: 500px;
border-radius: var(--border-radius);
border: 1px solid #ddd;
}
.location-controls {
padding: 20px;
background-color: #f9f9f9;
border-radius: var(--border-radius);
}
.location-controls .form-group {
margin-bottom: 15px;
}
.location-controls .form-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.help-text {
margin-top: 20px;
padding: 15px;
background-color: #e3f2fd;
border-radius: var(--border-radius);
font-size: 14px;
}
.help-text p {
margin: 0;
color: #1976d2;
}
.admin-info {
display: flex;
align-items: center;
gap: 10px;
color: rgba(255,255,255,0.9);
font-size: 14px;
}
/* Form styles */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: var(--dark-color);
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
/* Button styles */
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #45a049;
transform: translateY(-1px);
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
transform: translateY(-1px);
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
/* Status messages */
.status-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
max-width: 400px;
}
.status-message {
padding: 12px 16px;
margin-bottom: 10px;
border-radius: var(--border-radius);
font-size: 14px;
animation: slideIn 0.3s ease-out;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-message.info {
background-color: #cce7ff;
color: #004085;
border: 1px solid #b3d7ff;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Walk Sheet Styles */
.walk-sheet-container {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 30px;
margin-top: 20px;
align-items: flex-start;
}
.walk-sheet-config {
background-color: #f9f9f9;
padding: 20px;
border-radius: var(--border-radius);
}
.walk-sheet-config h3 {
font-size: 16px;
margin-bottom: 15px;
color: var(--dark-color);
}
.qr-code-group {
background-color: white;
padding: 15px;
border-radius: var(--border-radius);
margin-bottom: 15px;
border: 1px solid #e0e0e0;
position: relative;
}
.qr-code-group h4 {
font-size: 14px;
margin-bottom: 10px;
color: #666;
}
.form-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 10px;
}
.help-text-inline {
font-size: 14px;
color: #666;
margin-bottom: 15px;
}
/* Walk Sheet Preview */
.walk-sheet-preview {
background-color: #f5f5f5;
padding: 30px;
border-radius: var(--border-radius);
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
min-width: 350px;
max-width: 100%;
margin: 0 auto;
min-height: 950px; /* Ensure container is tall enough */
display: flex;
flex-direction: column;
align-items: center;
overflow: auto;
}
.walk-sheet-preview h3 {
font-size: 16px;
margin-bottom: 10px;
color: var(--dark-color);
}
.preview-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.preview-info {
font-size: 12px;
color: #666;
}
/* Walk Sheet Page (8.5 x 11 actual size) */
.walk-sheet-page {
/* Actual paper size in pixels at 96 DPI (standard screen resolution) */
width: 816px; /* 8.5 inches * 96 DPI */
height: 1056px; /* 11 inches * 96 DPI */
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
padding: 48px; /* 0.5 inch margins at 96 DPI */
margin: 0 auto;
overflow: hidden;
font-size: 12pt; /* Standard document font size */
line-height: 1.6;
position: relative;
transform: none; /* No scaling by default */
box-sizing: border-box;
}
/* Walk Sheet Print Styles */
@media print {
body * {
visibility: hidden;
}
.walk-sheet-page, .walk-sheet-page * {
visibility: visible;
}
.walk-sheet-page {
position: fixed;
left: 0;
top: 0;
width: 8.5in;
height: 11in;
max-width: none;
padding: 0.5in; /* 0.5 inch margins */
margin: 0;
box-shadow: none;
font-size: 12pt;
page-break-after: always;
}
/* Ensure QR codes print properly */
.ws-qr-code img {
width: 100px !important;
height: 100px !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* Remove the preview scaling - show at actual size */
.walk-sheet-preview .walk-sheet-page {
transform: none;
transform-origin: top center;
margin-bottom: 0;
border-radius: 0; /* Remove rounded corners to look more like paper */
}
/* Walk Sheet Content Styles - adjusted for actual size */
.ws-header {
text-align: center;
margin-bottom: 30px; /* Reduced from 40px */
border-bottom: 3px solid #333;
padding-bottom: 15px; /* Reduced from 20px */
}
.ws-title {
font-size: 24pt; /* Reduced from 28pt */
font-weight: bold;
margin: 0;
color: #000;
}
.ws-subtitle {
font-size: 12pt; /* Reduced from 14pt */
color: #444;
margin: 8px 0 0 0; /* Reduced from 10px */
}
.ws-qr-section {
display: flex;
justify-content: space-around;
margin: 30px 0; /* Reduced from 40px */
padding: 20px; /* Reduced from 30px */
background-color: #f5f5f5;
border-radius: 8px;
border: 1px solid #ddd;
}
.ws-qr-item {
text-align: center;
}
.ws-qr-code {
margin-bottom: 5px;
}
.ws-qr-code img {
display: block;
margin: 0 auto;
width: 120px; /* Reduced from 140px */
height: 120px;
image-rendering: crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
.ws-qr-code canvas {
display: block;
margin: 0 auto;
image-rendering: crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
}
.ws-qr-label {
font-size: 11pt;
font-weight: bold;
margin-top: 10px;
color: #333;
}
.ws-form-section {
margin-top: 30px; /* Reduced from 40px */
}
.ws-form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 18px; /* Reduced from 20px */
}
.ws-form-group {
border-bottom: 2px solid #ccc;
padding-bottom: 8px;
}
.ws-form-label {
font-size: 10pt;
color: #555;
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.ws-form-field {
height: 30px;
background-color: #fafafa;
border: 1px solid #eee;
}
/* Special field styles for circles */
.ws-form-field.circles {
height: auto;
background: none;
border: none;
display: flex;
gap: 15px;
align-items: center;
padding: 5px 0;
}
.ws-circle-option {
display: flex;
align-items: center;
gap: 5px;
font-size: 11pt;
}
.ws-circle {
width: 24px;
height: 24px;
border: 2px solid #333;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10pt;
font-weight: 500;
background-color: white;
}
.ws-notes-section {
margin-top: 30px; /* Reduced from 40px */
}
.ws-notes-label {
font-size: 11pt;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
.ws-notes-area {
width: 100%;
height: 80px; /* Reduced from 100px */
border: 2px solid #ccc;
background-color: #fafafa;
border-radius: 4px;
}
.ws-footer {
position: absolute;
bottom: 48px; /* Match padding */
left: 48px;
right: 48px;
text-align: center;
font-size: 10pt;
color: #666;
padding-top: 15px; /* Reduced from 20px */
border-top: 1px solid #ccc;
max-height: 80px; /* Ensure footer has enough space */
overflow: hidden;
line-height: 1.4;
}
/* QR Code Generation Status */
.qr-code-group.generating::after {
content: 'Generating QR Code...';
position: absolute;
top: 0;
right: 0;
font-size: 12px;
color: var(--primary-color);
background: white;
padding: 2px 8px;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Loading state for save button */
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* QR code stored indicator */
.qr-status {
font-size: 12px;
color: var(--success-color);
margin-top: 5px;
}
.qr-status.stored {
color: var(--success-color);
}
.qr-status.pending {
color: var(--warning-color);
}
/* Responsive - Scale down for smaller screens */
@media (max-width: 1400px) {
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.85);
transform-origin: top center;
}
.walk-sheet-preview {
min-height: 850px;
}
}
/* Mobile/Small Screen Layout - Stack config above preview */
@media (max-width: 1200px) {
.walk-sheet-container {
display: flex !important; /* Change from grid to flex */
flex-direction: column !important; /* Stack vertically */
gap: 20px;
}
.walk-sheet-config {
order: 1 !important; /* Config first */
margin-bottom: 20px;
}
.walk-sheet-preview {
order: 2 !important; /* Preview second */
padding: 20px;
min-height: auto;
max-width: 100vw;
overflow-x: auto; /* Allow horizontal scroll if needed */
display: flex;
justify-content: center; /* Center the page */
}
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.75);
transform-origin: top center;
margin-bottom: -200px;
max-width: 100%; /* Prevent overflow */
}
}
@media (max-width: 1000px) {
.walk-sheet-preview .walk-sheet-page {
transform: scale(0.5);
margin-bottom: -400px;
}
}
@media (max-width: 768px) {
.admin-container {
flex-direction: column;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.header .header-actions {
display: flex !important;
gap: 10px;
}
.header .header-actions .btn {
padding: 6px 10px;
font-size: 13px;
}
.admin-info {
font-size: 12px;
}
.admin-map-container {
grid-template-columns: 1fr;
}
.admin-map {
height: 220px;
}
.admin-content {
padding: 8px;
}
.admin-section {
padding: 10px;
}
.form-row {
grid-template-columns: 1fr;
}
.walk-sheet-preview {
min-width: 0;
max-width: 100%;
width: 100%;
min-height: 500px;
padding: 10px;
overflow: hidden; /* Change from overflow-x: hidden to overflow: hidden */
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
}
/* Container for the scaled page */
.walk-sheet-preview #walk-sheet-preview-content {
transform: scale(0.35);
transform-origin: top center;
margin: 0 auto;
width: 816px;
max-width: none;
margin-bottom: -600px;
position: relative;
left: 50%;
margin-left: -408px; /* Half of 816px to center it */
}
}
/* Even smaller screens */
@media (max-width: 480px) {
.walk-sheet-preview {
padding: 5px;
width: 100%;
}
.walk-sheet-preview #walk-sheet-preview-content {
transform: scale(0.25);
margin-bottom: -750px;
left: 50%;
margin-left: -408px; /* Keep centered */
}
}
/* For very large screens, show at full size without scaling */
@media (min-width: 1600px) {
.walk-sheet-container {
gap: 40px;
grid-template-columns: 1fr 2fr; /* Give more space to preview */
}
.walk-sheet-preview {
max-width: 100%;
min-height: 1100px;
padding: 40px;
}
.walk-sheet-preview .walk-sheet-page {
transform: none; /* Show at actual size */
}
}
/* Container adjustments for actual-size preview */
.walk-sheet-container {
display: grid;
grid-template-columns: minmax(300px, 400px) 1fr; /* Adjust proportions */
gap: 30px;
margin-top: 20px;
align-items: flex-start;
}
/* CSS Variables (define these in style.css if not already defined) */
:root {
--primary-color: #4CAF50;
--dark-color: #333;
--light-color: #f5f5f5;
--border-radius: 6px;
--transition: all 0.2s ease;
--header-height: 60px;
}
/* Crosshair styling */
.crosshair {
pointer-events: none;
z-index: 1000;
}
.crosshair div {
border-radius: 1px;
opacity: 0.8;
}
/* Admin map styling */
.admin-map {
position: relative;
cursor: crosshair;
}
.admin-map .leaflet-container {
cursor: crosshair;
}
/* Shifts Admin Styles */
.shifts-admin-container {
display: grid;
grid-template-columns: 400px 1fr;
gap: 30px;
}
.shift-form {
background: white;
padding: 20px;
border-radius: var(--border-radius);
border: 1px solid #e0e0e0;
}
.shifts-list {
background: white;
padding: 20px;
border-radius: var(--border-radius);
border: 1px solid #e0e0e0;
}
.shift-admin-item {
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
padding: 15px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.shift-admin-item h4 {
margin: 0 0 10px 0;
}
.shift-admin-item p {
margin: 5px 0;
color: var(--secondary-color);
}
.status-open {
color: var(--success-color);
font-weight: bold;
}
.status-full {
color: var(--warning-color);
font-weight: bold;
}
.status-cancelled {
color: var(--danger-color);
font-weight: bold;
}
.shift-actions {
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.shifts-admin-container {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,161 @@
.shifts-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* My Signups section - now at the top */
.my-signups {
margin-bottom: 40px;
padding-bottom: 30px;
border-bottom: 1px solid #e0e0e0;
}
.my-signups h2 {
margin-bottom: 20px;
color: var(--dark-color);
}
.signup-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.signup-item h4 {
margin: 0 0 5px 0;
}
.signup-item p {
margin: 0;
color: var(--secondary-color);
}
/* Filters section */
.shifts-filters {
margin-bottom: 30px;
}
.shifts-filters h2 {
margin-bottom: 15px;
color: var(--dark-color);
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.filter-group input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
}
/* Desktop: 3 columns layout */
.shifts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 40px;
}
.shift-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
padding: 20px;
transition: var(--transition);
}
.shift-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.shift-card.full {
opacity: 0.7;
}
.shift-card.signed-up {
border-color: var(--success-color);
background-color: #f0f9ff;
}
.shift-card h3 {
margin: 0 0 15px 0;
color: var(--dark-color);
}
.shift-details {
margin-bottom: 15px;
}
.shift-details p {
margin: 5px 0;
color: var(--secondary-color);
}
.shift-description {
margin: 15px 0;
color: var(--dark-color);
}
.shift-actions {
margin-top: 15px;
}
.no-shifts {
text-align: center;
color: var(--secondary-color);
padding: 40px;
grid-column: 1 / -1; /* Span all columns */
}
/* Tablet: 2 columns */
@media (max-width: 1024px) and (min-width: 769px) {
.shifts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Mobile: 1 column */
@media (max-width: 768px) {
.shifts-container {
padding: 15px;
}
.shifts-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.signup-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.shift-card {
padding: 15px;
}
.my-signups {
margin-bottom: 30px;
padding-bottom: 20px;
}
.shifts-filters {
margin-bottom: 20px;
}
.filter-group {
flex-wrap: wrap;
}
}

View File

@ -1,12 +1,12 @@
/* CSS Variables for theming */
:root {
--primary-color: #2c5aa0;
--primary-color: #a02c8d;
--success-color: #27ae60;
--danger-color: #e74c3c;
--warning-color: #f39c12;
--secondary-color: #95a5a6;
--dark-color: #2c3e50;
--light-color: #ecf0f1;
--secondary-color: #ba6cdf;
--dark-color: #2e053f;
--light-color: #efcef0;
--border-radius: 4px;
--transition: all 0.3s ease;
--header-height: 60px;
@ -46,6 +46,7 @@ body {
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 1000;
position: relative;
}
.header h1 {
@ -83,12 +84,17 @@ body {
/* Map container */
#map-container {
flex: 1;
position: relative;
overflow: hidden;
width: 100%;
height: calc(100vh - var(--header-height));
}
#map {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #f0f0f0;
@ -549,39 +555,266 @@ body {
border-top: 1px solid #eee;
}
/* Responsive design */
@media (max-width: 768px) {
.header h1 {
font-size: 20px;
/* Popup actions section */
.popup-content .popup-actions {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
display: flex;
gap: 10px;
justify-content: center;
}
.popup-content .popup-actions .btn {
flex: 1;
max-width: 120px;
}
/* Distinctive start location marker styles */
.start-location-custom-marker {
z-index: 2000 !important;
}
.start-location-marker-wrapper {
position: relative;
width: 48px;
height: 48px;
}
.start-location-marker-pin {
position: absolute;
width: 48px;
height: 48px;
background: #ff4444;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: 3px solid white;
animation: bounce-marker 2s ease-in-out infinite;
}
.start-location-marker-inner {
transform: rotate(45deg);
width: 24px;
height: 24px;
}
.start-location-marker-pulse {
position: absolute;
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 68, 68, 0.3);
animation: pulse-ring 2s ease-out infinite;
}
@keyframes bounce-marker {
0%, 100% {
transform: rotate(-45deg) translateY(0);
}
50% {
transform: rotate(-45deg) translateY(-5px);
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.5);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
/* Enhanced popup for start location */
.start-location-popup-enhanced .leaflet-popup-content-wrapper {
padding: 0;
overflow: hidden;
border: none;
box-shadow: 0 5px 20px rgba(0,0,0,0.3);
}
.start-location-popup-enhanced .leaflet-popup-content {
margin: 0;
}
/* Mobile dropdown menu */
.mobile-dropdown {
position: relative;
display: none;
}
.mobile-dropdown-toggle {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 8px;
border-radius: var(--border-radius);
transition: var(--transition);
display: flex;
align-items: center;
gap: 5px;
}
.mobile-dropdown-toggle:hover {
background-color: rgba(255,255,255,0.1);
}
.mobile-dropdown-content {
position: absolute;
top: 100%;
right: 0;
background-color: white;
color: var(--dark-color);
min-width: 250px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border-radius: var(--border-radius);
overflow: hidden;
transform: translateY(-10px);
opacity: 0;
visibility: hidden;
transition: var(--transition);
z-index: 1001;
}
.mobile-dropdown.active .mobile-dropdown-content {
transform: translateY(0);
opacity: 1;
visibility: visible;
}
.mobile-dropdown-item {
padding: 12px 15px;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.mobile-dropdown-item:last-child {
border-bottom: none;
}
.mobile-dropdown-item.location-info {
background-color: var(--primary-color);
color: white;
font-weight: 500;
}
.mobile-dropdown-item.user-info {
background-color: var(--light-color);
color: var(--dark-color);
}
/* Floating sidebar for mobile */
.mobile-sidebar {
position: fixed;
top: 50%;
right: 10px;
transform: translateY(-50%);
background-color: white;
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
display: none;
flex-direction: column;
gap: 5px;
padding: 8px;
}
.mobile-sidebar .btn {
margin: 0;
min-width: 44px;
min-height: 44px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
/* Active state for mobile buttons */
.mobile-sidebar .btn.active {
background-color: var(--dark-color);
color: white;
}
.mobile-sidebar .btn:active {
transform: scale(0.95);
}
/* Desktop styles - show normal layout */
@media (min-width: 769px) {
.mobile-dropdown {
display: none;
}
.mobile-sidebar {
display: none;
}
.header-actions {
display: flex;
}
.user-info,
.location-count {
display: flex;
}
.map-controls {
top: 10px;
right: 10px;
display: flex;
}
.btn {
padding: 8px 12px;
font-size: 13px;
/* Show shifts button on desktop */
.header-actions a[href="/shifts.html"] {
display: inline-flex !important;
}
/* Hide button text on mobile, show only icons */
.btn span.btn-text {
.btn span.btn-icon {
margin-right: 5px;
}
}
/* Hide desktop elements on mobile */
@media (max-width: 768px) {
.header h1 {
font-size: 18px;
}
.header-actions {
display: none;
}
/* Hide user info on mobile to save space */
.user-info {
/* Hide any floating shifts button on mobile - but NOT the one in dropdown */
.header-actions a[href="/shifts.html"] {
display: none !important;
}
.mobile-dropdown {
display: block;
}
.mobile-sidebar {
display: flex;
}
.map-controls {
display: none;
}
.btn {
padding: 10px;
min-width: 40px;
min-height: 40px;
justify-content: center;
/* Hide user info and location count on desktop header for mobile */
.user-info,
.location-count {
display: none;
}
/* Adjust modal for mobile */
.modal-content {
width: 95%;
margin: 10px;
@ -590,12 +823,14 @@ body {
.form-row {
grid-template-columns: 1fr;
}
}
/* Add text spans for desktop that can be hidden on mobile */
@media (min-width: 769px) {
.btn span.btn-icon {
margin-right: 5px;
/* Adjust edit footer for mobile */
.edit-footer-content {
padding: 15px;
}
.edit-footer-header h2 {
font-size: 18px;
}
}
@ -626,3 +861,63 @@ body {
height: 100vh;
}
}
/* Leaflet Circle Markers - Add this section */
.leaflet-marker-icon {
background-color: transparent !important;
border: none !important;
}
.leaflet-interactive {
cursor: pointer;
}
/* Ensure circle markers are visible */
path.leaflet-interactive {
stroke: #fff;
stroke-opacity: 1;
stroke-width: 2;
fill-opacity: 0.8;
}
/* Fix for marker z-index */
.leaflet-pane.leaflet-marker-pane {
z-index: 600;
}
.leaflet-pane.leaflet-tooltip-pane {
z-index: 650;
}
.leaflet-pane.leaflet-popup-pane {
z-index: 700;
}
/* Ensure markers are above the map tiles */
.leaflet-marker-pane svg {
position: relative;
z-index: 1;
}
/* Force circle markers to be visible */
.leaflet-overlay-pane svg {
z-index: 1;
}
.leaflet-overlay-pane svg path {
cursor: pointer;
pointer-events: auto;
}
/* Ensure SVG circle markers are rendered */
.location-marker {
cursor: pointer !important;
}
/* Override any conflicting styles */
.leaflet-container path.leaflet-interactive {
stroke: #ffffff !important;
stroke-opacity: 1 !important;
stroke-width: 2px !important;
fill-opacity: 0.8 !important;
}

View File

@ -0,0 +1 @@
<!-- Simple favicon placeholder -->

View File

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Interactive map viewer for NocoDB location data">
<title>NocoDB Map Viewer</title>
<meta name="description" content="Interactive canvassing web-app & viewer Changemaker-lite nocodb">
<title>Map by BNKops</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@ -18,37 +18,90 @@
<div id="app">
<!-- Header -->
<header class="header">
<h1>Location Map Viewer</h1>
<h1>Map for CM-lite</h1>
<div class="header-actions">
<button id="refresh-btn" class="btn btn-secondary" title="Refresh locations">
🔄 Refresh
<a href="/shifts.html" class="btn btn-secondary">
<span class="btn-icon">📅</span>
<span class="btn-text">View Shifts</span>
</a>
<div class="user-info">
<span class="user-email" id="user-email">Loading...</span>
</div>
<div class="location-count" id="location-count">0 locations</div>
</div>
<!-- Mobile dropdown menu -->
<div class="mobile-dropdown" id="mobile-dropdown">
<button class="mobile-dropdown-toggle" id="mobile-dropdown-toggle">
<span></span>
</button>
<span id="location-count" class="location-count">Loading...</span>
<div class="mobile-dropdown-content" id="mobile-dropdown-content">
<div class="mobile-dropdown-item">
<a href="/shifts.html" style="color: inherit; text-decoration: none;">📅 View Shifts</a>
</div>
<!-- Admin link will be added here dynamically if user is admin -->
<div class="mobile-dropdown-item location-info">
<span id="mobile-location-count">0 locations</span>
</div>
<div class="mobile-dropdown-item user-info">
<span id="mobile-user-email">Loading...</span>
</div>
</div>
</div>
</header>
<!-- Map Container -->
<!-- Map container -->
<div id="map-container">
<div id="map"></div>
<!-- Map Controls -->
<!-- Desktop map controls -->
<div class="map-controls">
<button id="geolocate-btn" class="btn btn-primary" title="Find my location">
<span class="btn-icon">📍</span><span class="btn-text">My Location</span>
<button id="toggle-start-location-btn" class="btn btn-secondary">
<span class="btn-icon">🏠</span>
<span class="btn-text">Hide Start Location</span>
</button>
<button id="add-location-btn" class="btn btn-success" title="Add location at map center">
<span class="btn-icon"></span><span class="btn-text">Add Location Here</span>
<button id="refresh-btn" class="btn btn-primary">
<span class="btn-icon">🔄</span>
<span class="btn-text">Refresh</span>
</button>
<button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen">
<span class="btn-icon"></span><span class="btn-text">Fullscreen</span>
<button id="geolocate-btn" class="btn btn-secondary">
<span class="btn-icon">📍</span>
<span class="btn-text">Find Me</span>
</button>
<button id="add-location-btn" class="btn btn-success">
<span class="btn-icon"></span>
<span class="btn-text">Add Location Here</span>
</button>
<button id="fullscreen-btn" class="btn btn-secondary">
<span class="btn-icon"></span>
<span class="btn-text">Fullscreen</span>
</button>
</div>
<!-- Crosshair for adding locations -->
<!-- Mobile floating sidebar -->
<div class="mobile-sidebar" id="mobile-sidebar">
<button id="mobile-toggle-start-location-btn" class="btn btn-secondary" title="Toggle Start Location">
🏠
</button>
<button id="mobile-refresh-btn" class="btn btn-primary" title="Refresh">
🔄
</button>
<button id="mobile-geolocate-btn" class="btn btn-secondary" title="Find Me">
📍
</button>
<button id="mobile-add-location-btn" class="btn btn-success" title="Add Location">
</button>
<button id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
</button>
</div>
<!-- Crosshair for location selection -->
<div id="crosshair" class="crosshair hidden">
<div class="crosshair-x"></div>
<div class="crosshair-y"></div>
<div class="crosshair-info">Click "Add Location Here" to save this point</div>
<div class="crosshair-info">Click to add location</div>
</div>
</div>
@ -63,7 +116,8 @@
<button class="btn btn-secondary btn-sm" id="close-edit-footer-btn">✕ Close</button>
</div>
<form id="edit-location-form">
<input type="hidden" id="edit-location-id" name="id">
<!-- Hidden ID field - don't include in form data -->
<input type="hidden" id="edit-location-id" data-exclude="true">
<div class="form-row">
<div class="form-group">
<label for="edit-first-name">First Name</label>
@ -79,6 +133,11 @@
<label for="edit-location-email">Email</label>
<input type="email" id="edit-location-email" name="Email">
</div>
<div class="form-group">
<label for="edit-location-phone">Phone Number</label>
<input type="tel" id="edit-location-phone" name="Phone">
</div>
<div class="form-row">
<div class="form-group">
@ -102,7 +161,7 @@
<div style="display: flex; gap: 10px;">
<input type="text" id="edit-location-address" name="Address" style="flex: 1;">
<button type="button" class="btn btn-secondary btn-sm" id="lookup-address-edit-btn">
🔍 Lookup
📍 Lookup Address
</button>
</div>
</div>
@ -124,6 +183,11 @@
</select>
</div>
</div>
<div class="form-group">
<label for="edit-location-notes">Notes</label>
<textarea id="edit-location-notes" name="Notes" rows="4"></textarea>
</div>
<div class="form-row">
<div class="form-group">
@ -177,6 +241,12 @@
<input type="email" id="location-email" name="Email"
placeholder="Enter email address">
</div>
<div class="form-group">
<label for="location-phone">Phone Number</label>
<input type="tel" id="location-phone" name="Phone"
placeholder="Enter phone number">
</div>
<div class="form-row">
<div class="form-group">
@ -202,7 +272,7 @@
<input type="text" id="location-address" name="Address"
placeholder="Enter address" style="flex: 1;">
<button type="button" class="btn btn-secondary btn-sm" id="lookup-address-add-btn">
🔍 Lookup
📍 Lookup Address
</button>
</div>
</div>
@ -224,6 +294,12 @@
</select>
</div>
</div>
<div class="form-group">
<label for="location-notes">Notes</label>
<textarea id="location-notes" name="Notes" rows="4"
placeholder="Enter additional details"></textarea>
</div>
<div class="form-row">
<div class="form-group">
@ -271,6 +347,6 @@
crossorigin=""></script>
<!-- Application JavaScript -->
<script src="js/map.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

1212
map/app/public/js/admin.js Normal file

File diff suppressed because it is too large Load Diff

81
map/app/public/js/auth.js Normal file
View File

@ -0,0 +1,81 @@
// Authentication related functions
import { showStatus } from './utils.js';
export let currentUser = null;
export async function checkAuth() {
try {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (!data.authenticated) {
window.location.href = '/login.html';
throw new Error('Not authenticated');
}
currentUser = data.user;
updateUserInterface();
} catch (error) {
console.error('Auth check failed:', error);
window.location.href = '/login.html';
throw error;
}
}
export function updateUserInterface() {
if (!currentUser) return;
// Update user email in both desktop and mobile
const userEmailElement = document.getElementById('user-email');
const mobileUserEmailElement = document.getElementById('mobile-user-email');
if (userEmailElement) {
userEmailElement.textContent = currentUser.email;
}
if (mobileUserEmailElement) {
mobileUserEmailElement.textContent = currentUser.email;
}
// Add admin link if user is admin
if (currentUser.isAdmin) {
addAdminLinks();
}
}
function addAdminLinks() {
// Add admin link to desktop header
const headerActions = document.querySelector('.header-actions');
if (headerActions) {
const adminLink = document.createElement('a');
adminLink.href = '/admin.html';
adminLink.className = 'btn btn-secondary';
adminLink.textContent = '⚙️ Admin';
headerActions.insertBefore(adminLink, headerActions.firstChild);
}
// Add admin link to mobile dropdown
const mobileDropdownContent = document.getElementById('mobile-dropdown-content');
if (mobileDropdownContent) {
// Check if admin link already exists
if (!mobileDropdownContent.querySelector('.admin-link-mobile')) {
const adminItem = document.createElement('div');
adminItem.className = 'mobile-dropdown-item admin-link-mobile';
const adminLink = document.createElement('a');
adminLink.href = '/admin.html';
adminLink.style.color = 'inherit';
adminLink.style.textDecoration = 'none';
adminLink.textContent = '⚙️ Admin Panel';
adminItem.appendChild(adminLink);
// Insert admin link at the top of the dropdown
if (mobileDropdownContent.firstChild) {
mobileDropdownContent.insertBefore(adminItem, mobileDropdownContent.firstChild);
} else {
mobileDropdownContent.appendChild(adminItem);
}
}
}
}

View File

@ -0,0 +1,9 @@
// Global configuration
export const CONFIG = {
DEFAULT_LAT: parseFloat(document.querySelector('meta[name="default-lat"]')?.content) || 53.5461,
DEFAULT_LNG: parseFloat(document.querySelector('meta[name="default-lng"]')?.content) || -113.4938,
DEFAULT_ZOOM: parseInt(document.querySelector('meta[name="default-zoom"]')?.content) || 11,
REFRESH_INTERVAL: 30000, // 30 seconds
MAX_ZOOM: 20,
MIN_ZOOM: 2
};

View File

@ -0,0 +1,367 @@
// Location management (CRUD operations)
import { map } from './map-manager.js';
import { showStatus, updateLocationCount, escapeHtml } from './utils.js';
import { currentUser } from './auth.js';
export let markers = [];
export let currentEditingLocation = null;
export async function loadLocations() {
try {
const response = await fetch('/api/locations');
const data = await response.json();
if (data.success) {
displayLocations(data.locations);
updateLocationCount(data.locations.length);
} else {
throw new Error(data.error || 'Failed to load locations');
}
} catch (error) {
console.error('Error loading locations:', error);
showStatus('Failed to load locations', 'error');
}
}
export function displayLocations(locations) {
// Clear existing markers
markers.forEach(marker => {
if (marker && map) {
map.removeLayer(marker);
}
});
markers = [];
// Add new markers
locations.forEach(location => {
if (location.latitude && location.longitude) {
const marker = createLocationMarker(location);
if (marker) {
markers.push(marker);
}
}
});
console.log(`Displayed ${markers.length} locations`);
}
function createLocationMarker(location) {
if (!map) {
console.warn('Map not initialized, skipping marker creation');
return null;
}
// Try to get coordinates from multiple possible sources
let lat, lng;
// First try the Geo-Location field
if (location['Geo-Location']) {
const coords = location['Geo-Location'].split(';');
if (coords.length === 2) {
lat = parseFloat(coords[0]);
lng = parseFloat(coords[1]);
}
}
// If that didn't work, try latitude/longitude fields
if ((!lat || !lng) && location.latitude && location.longitude) {
lat = parseFloat(location.latitude);
lng = parseFloat(location.longitude);
}
// Validate coordinates
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
console.warn('Invalid coordinates for location:', location);
return null;
}
// Determine marker color based on support level
let markerColor = '#3388ff'; // Default blue
if (location['Support Level']) {
const level = parseInt(location['Support Level']);
switch(level) {
case 1: markerColor = '#27ae60'; break; // Green
case 2: markerColor = '#f1c40f'; break; // Yellow
case 3: markerColor = '#e67e22'; break; // Orange
case 4: markerColor = '#e74c3c'; break; // Red
}
}
// Create circle marker with explicit styling
const marker = L.circleMarker([lat, lng], {
radius: 8,
fillColor: markerColor,
color: '#ffffff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
className: 'location-marker' // Add a class for CSS targeting
});
// Add to map
marker.addTo(map);
const popupContent = createPopupContent(location);
marker.bindPopup(popupContent);
marker._locationData = location;
console.log(`Created marker at ${lat}, ${lng} with color ${markerColor}`);
return marker;
}
function createPopupContent(location) {
const locationId = location.Id || location.id || location.ID || location._id;
const name = [location['First Name'], location['Last Name']]
.filter(Boolean).join(' ') || 'Unknown';
const address = location.Address || 'No address';
const supportLevel = location['Support Level'] ?
`Level ${location['Support Level']}` : 'Not specified';
return `
<div class="popup-content">
<h3>${escapeHtml(name)}</h3>
<p><strong>Address:</strong> ${escapeHtml(address)}</p>
<p><strong>Support:</strong> ${escapeHtml(supportLevel)}</p>
${location.Sign ? '<p>🏁 Has campaign sign</p>' : ''}
${location.Notes ? `<p><strong>Notes:</strong> ${escapeHtml(location.Notes)}</p>` : ''}
<div class="popup-meta">
<p>ID: ${locationId || 'Unknown'}</p>
</div>
${currentUser ? `
<div class="popup-actions">
<button class="btn btn-primary btn-sm edit-location-popup-btn"
data-location='${escapeHtml(JSON.stringify(location))}'>
Edit
</button>
</div>
` : ''}
</div>
`;
}
export async function handleAddLocation(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = {};
// Convert form data to object
for (let [key, value] of formData.entries()) {
// Map form field names to NocoDB column names
if (key === 'latitude') data.latitude = value.trim();
else if (key === 'longitude') data.longitude = value.trim();
else if (key === 'Geo-Location') data['Geo-Location'] = value.trim();
else if (value.trim() !== '') {
data[key] = value.trim();
}
}
// Ensure geo-location is set
if (data.latitude && data.longitude) {
data['Geo-Location'] = `${data.latitude};${data.longitude}`;
}
// Handle checkbox
data.Sign = document.getElementById('sign').checked;
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showStatus('Location added successfully!', 'success');
closeAddModal();
loadLocations();
} else {
throw new Error(result.error || 'Failed to add location');
}
} catch (error) {
console.error('Error adding location:', error);
showStatus(error.message || 'Failed to add location', 'error');
}
}
export function openEditForm(location) {
currentEditingLocation = location;
// Extract ID - check multiple possible field names
const locationId = location.Id || location.id || location.ID || location._id;
if (!locationId) {
console.error('No ID found in location object. Available fields:', Object.keys(location));
showStatus('Error: Location ID not found. Check console for details.', 'error');
return;
}
// Store the ID in a data attribute for later use
document.getElementById('edit-location-id').value = locationId;
document.getElementById('edit-location-id').setAttribute('data-location-id', locationId);
// Populate form fields
document.getElementById('edit-first-name').value = location['First Name'] || '';
document.getElementById('edit-last-name').value = location['Last Name'] || '';
document.getElementById('edit-location-email').value = location.Email || '';
document.getElementById('edit-location-phone').value = location.Phone || '';
document.getElementById('edit-location-unit').value = location['Unit Number'] || '';
document.getElementById('edit-support-level').value = location['Support Level'] || '';
document.getElementById('edit-location-address').value = location.Address || '';
document.getElementById('edit-sign').checked = location.Sign === true || location.Sign === 'true' || location.Sign === 1;
document.getElementById('edit-sign-size').value = location['Sign Size'] || '';
document.getElementById('edit-location-notes').value = location.Notes || '';
document.getElementById('edit-location-lat').value = location.latitude || '';
document.getElementById('edit-location-lng').value = location.longitude || '';
document.getElementById('edit-geo-location').value = location['Geo-Location'] || '';
// Show edit footer
document.getElementById('edit-footer').classList.remove('hidden');
}
export function closeEditForm() {
document.getElementById('edit-footer').classList.add('hidden');
currentEditingLocation = null;
}
export async function handleEditLocation(e) {
e.preventDefault();
if (!currentEditingLocation) return;
// Get the stored location ID
const locationIdElement = document.getElementById('edit-location-id');
const locationId = locationIdElement.getAttribute('data-location-id') || locationIdElement.value;
if (!locationId || locationId === 'undefined') {
showStatus('Error: Location ID not found', 'error');
return;
}
const formData = new FormData(e.target);
const data = {};
// Convert form data to object
for (let [key, value] of formData.entries()) {
// Skip the ID field
if (key === 'id' || key === 'Id' || key === 'ID') continue;
if (value !== null && value !== undefined) {
data[key] = value.trim();
}
}
// Ensure geo-location is set
if (data.latitude && data.longitude) {
data['Geo-Location'] = `${data.latitude};${data.longitude}`;
}
// Handle checkbox
data.Sign = document.getElementById('edit-sign').checked;
try {
const response = await fetch(`/api/locations/${locationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const responseText = await response.text();
let result;
try {
result = JSON.parse(responseText);
} catch (e) {
console.error('Failed to parse response:', responseText);
throw new Error(`Server response error: ${response.status} ${response.statusText}`);
}
if (result.success) {
showStatus('Location updated successfully!', 'success');
closeEditForm();
loadLocations();
} else {
throw new Error(result.error || 'Failed to update location');
}
} catch (error) {
console.error('Error updating location:', error);
showStatus(`Update failed: ${error.message}`, 'error');
}
}
export async function handleDeleteLocation() {
if (!currentEditingLocation) return;
// Get the stored location ID
const locationIdElement = document.getElementById('edit-location-id');
const locationId = locationIdElement.getAttribute('data-location-id') || locationIdElement.value;
if (!locationId || locationId === 'undefined') {
showStatus('Error: Location ID not found', 'error');
return;
}
if (!confirm('Are you sure you want to delete this location?')) {
return;
}
try {
const response = await fetch(`/api/locations/${locationId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
showStatus('Location deleted successfully!', 'success');
closeEditForm();
loadLocations();
} else {
throw new Error(result.error || 'Failed to delete location');
}
} catch (error) {
console.error('Error deleting location:', error);
showStatus(error.message || 'Failed to delete location', 'error');
}
}
export function closeAddModal() {
const modal = document.getElementById('add-modal');
modal.classList.add('hidden');
document.getElementById('location-form').reset();
}
export function openAddModal(lat, lng) {
const modal = document.getElementById('add-modal');
const latInput = document.getElementById('location-lat');
const lngInput = document.getElementById('location-lng');
const geoInput = document.getElementById('geo-location');
// Set coordinates
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
// Clear other fields
document.getElementById('location-form').reset();
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
// Show modal
modal.classList.remove('hidden');
// Trigger custom event for auto address lookup
const autoLookupEvent = new CustomEvent('autoAddressLookup', {
detail: { mode: 'add', lat, lng }
});
document.dispatchEvent(autoLookupEvent);
}

49
map/app/public/js/main.js Normal file
View File

@ -0,0 +1,49 @@
// Main application entry point
import { CONFIG } from './config.js';
import { hideLoading, showStatus } from './utils.js';
import { checkAuth } from './auth.js';
import { initializeMap } from './map-manager.js';
import { loadLocations } from './location-manager.js';
import { setupEventListeners } from './ui-controls.js';
// Application state
let refreshInterval = null;
// Initialize the application
document.addEventListener('DOMContentLoaded', async () => {
console.log('DOM loaded, initializing application...');
try {
// First check authentication
await checkAuth();
// Then initialize the map
await initializeMap();
// Only load locations after map is ready
await loadLocations();
// Setup other features
setupEventListeners();
setupAutoRefresh();
} catch (error) {
console.error('Initialization error:', error);
showStatus('Failed to initialize application', 'error');
} finally {
hideLoading();
}
});
function setupAutoRefresh() {
refreshInterval = setInterval(() => {
loadLocations();
}, CONFIG.REFRESH_INTERVAL);
}
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});

View File

@ -0,0 +1,107 @@
// Map initialization and management
import { CONFIG } from './config.js';
import { showStatus } from './utils.js';
import { currentUser } from './auth.js';
export let map = null;
export let startLocationMarker = null;
export let isStartLocationVisible = true;
export async function initializeMap() {
try {
// Get start location from PUBLIC endpoint (not admin endpoint)
const response = await fetch('/api/config/start-location');
const data = await response.json();
let startLat = CONFIG.DEFAULT_LAT;
let startLng = CONFIG.DEFAULT_LNG;
let startZoom = CONFIG.DEFAULT_ZOOM;
if (data.success && data.location) {
startLat = data.location.latitude;
startLng = data.location.longitude;
startZoom = data.location.zoom;
}
// Initialize map
map = L.map('map').setView([startLat, startLng], startZoom);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: CONFIG.MAX_ZOOM,
minZoom: CONFIG.MIN_ZOOM
}).addTo(map);
// Add start location marker
addStartLocationMarker(startLat, startLng);
console.log('Map initialized successfully');
} catch (error) {
console.error('Failed to initialize map:', error);
showStatus('Failed to initialize map', 'error');
}
}
function addStartLocationMarker(lat, lng) {
console.log(`Adding start location marker at: ${lat}, ${lng}`);
// Remove existing start location marker if it exists
if (startLocationMarker) {
map.removeLayer(startLocationMarker);
}
// Create a very distinctive custom icon
const startIcon = L.divIcon({
html: `
<div class="start-location-marker-wrapper">
<div class="start-location-marker-pin">
<div class="start-location-marker-inner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"/>
</svg>
</div>
</div>
<div class="start-location-marker-pulse"></div>
</div>
`,
className: 'start-location-custom-marker',
iconSize: [48, 48],
iconAnchor: [24, 48],
popupAnchor: [0, -48]
});
// Create the marker
startLocationMarker = L.marker([lat, lng], {
icon: startIcon,
zIndexOffset: 1000
}).addTo(map);
// Add popup
startLocationMarker.bindPopup(`
<div class="popup-content start-location-popup-enhanced">
<h3>📍 Map Start Location</h3>
<p>This is todays starting location!</p>
${currentUser?.isAdmin ? '<p><a href="/admin.html">Edit in Admin Panel</a></p>' : ''}
</div>
`);
}
export function toggleStartLocationVisibility() {
if (!startLocationMarker) return;
isStartLocationVisible = !isStartLocationVisible;
if (isStartLocationVisible) {
map.addLayer(startLocationMarker);
// Update both desktop and mobile button text
const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text');
if (desktopBtn) desktopBtn.textContent = 'Hide Start Location';
} else {
map.removeLayer(startLocationMarker);
// Update both desktop and mobile button text
const desktopBtn = document.querySelector('#toggle-start-location-btn .btn-text');
if (desktopBtn) desktopBtn.textContent = 'Show Start Location';
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

293
map/app/public/js/shifts.js Normal file
View File

@ -0,0 +1,293 @@
let currentUser = null;
let allShifts = [];
let mySignups = [];
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', async () => {
await checkAuth();
await loadShifts();
await loadMySignups();
setupEventListeners();
// Add clear filters button handler
const clearBtn = document.getElementById('clear-filters-btn');
if (clearBtn) {
clearBtn.addEventListener('click', clearFilters);
}
});
async function checkAuth() {
try {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (!data.authenticated) {
window.location.href = '/login.html';
return;
}
currentUser = data.user;
document.getElementById('user-email').textContent = currentUser.email;
// Add admin link if user is admin
if (currentUser.isAdmin) {
const headerActions = document.querySelector('.header-actions');
const adminLink = document.createElement('a');
adminLink.href = '/admin.html#shifts';
adminLink.className = 'btn btn-secondary';
adminLink.textContent = '⚙️ Manage Shifts';
headerActions.insertBefore(adminLink, headerActions.firstChild);
}
} catch (error) {
console.error('Auth check failed:', error);
window.location.href = '/login.html';
}
}
async function loadShifts() {
try {
const response = await fetch('/api/shifts');
const data = await response.json();
if (data.success) {
allShifts = data.shifts;
displayShifts(allShifts);
}
} catch (error) {
showStatus('Failed to load shifts', 'error');
}
}
async function loadMySignups() {
try {
const response = await fetch('/api/shifts/my-signups');
const data = await response.json();
if (data.success) {
mySignups = data.signups;
displayMySignups();
} else {
// Still display empty signups if the endpoint fails
mySignups = [];
displayMySignups();
}
} catch (error) {
console.error('Failed to load signups:', error);
// Don't show error to user, just display empty signups
mySignups = [];
displayMySignups();
}
}
function displayShifts(shifts) {
const grid = document.getElementById('shifts-grid');
if (shifts.length === 0) {
grid.innerHTML = '<p class="no-shifts">No shifts available at this time.</p>';
return;
}
grid.innerHTML = shifts.map(shift => {
const shiftDate = new Date(shift.Date);
const isSignedUp = mySignups.some(s => s.shift_id === shift.ID);
const isFull = shift['Current Volunteers'] >= shift['Max Volunteers'];
return `
<div class="shift-card ${isFull ? 'full' : ''} ${isSignedUp ? 'signed-up' : ''}">
<h3>${escapeHtml(shift.Title)}</h3>
<div class="shift-details">
<p>📅 ${shiftDate.toLocaleDateString()}</p>
<p> ${shift['Start Time']} - ${shift['End Time']}</p>
<p>📍 ${escapeHtml(shift.Location || 'TBD')}</p>
<p>👥 ${shift['Current Volunteers']}/${shift['Max Volunteers']} volunteers</p>
</div>
${shift.Description ? `<div class="shift-description">${escapeHtml(shift.Description)}</div>` : ''}
<div class="shift-actions">
${isSignedUp
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>`
: isFull
? '<button class="btn btn-secondary btn-sm" disabled>Shift Full</button>'
: `<button class="btn btn-primary btn-sm signup-btn" data-shift-id="${shift.ID}">Sign Up</button>`
}
</div>
</div>
`;
}).join('');
// Add event listeners after rendering
setupShiftCardListeners();
}
function displayMySignups() {
const list = document.getElementById('my-signups-list');
if (mySignups.length === 0) {
list.innerHTML = '<p>You haven\'t signed up for any shifts yet.</p>';
return;
}
// Need to match signups with shift details
const signupsWithDetails = mySignups.map(signup => {
const shift = allShifts.find(s => s.ID === signup.shift_id);
return { ...signup, shift };
}).filter(s => s.shift);
list.innerHTML = signupsWithDetails.map(signup => {
const shiftDate = new Date(signup.shift.Date);
return `
<div class="signup-item">
<div>
<h4>${escapeHtml(signup.shift.Title)}</h4>
<p>📅 ${shiftDate.toLocaleDateString()} ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
</div>
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
</div>
`;
}).join('');
// Add event listeners after rendering
setupMySignupsListeners();
}
// New function to setup listeners for shift cards
function setupShiftCardListeners() {
const grid = document.getElementById('shifts-grid');
if (!grid) return;
// Remove any existing listeners by cloning
const newGrid = grid.cloneNode(true);
grid.parentNode.replaceChild(newGrid, grid);
// Add click listener for signup buttons
newGrid.addEventListener('click', async (e) => {
if (e.target.classList.contains('signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await signupForShift(shiftId);
} else if (e.target.classList.contains('cancel-signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await cancelSignup(shiftId);
}
});
}
// New function to setup listeners for my signups
function setupMySignupsListeners() {
const list = document.getElementById('my-signups-list');
if (!list) return;
// Remove any existing listeners by cloning
const newList = list.cloneNode(true);
list.parentNode.replaceChild(newList, list);
// Add click listener for cancel buttons
newList.addEventListener('click', async (e) => {
if (e.target.classList.contains('cancel-signup-btn')) {
const shiftId = e.target.getAttribute('data-shift-id');
await cancelSignup(shiftId);
}
});
}
async function signupForShift(shiftId) {
try {
const response = await fetch(`/api/shifts/${shiftId}/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showStatus('Successfully signed up for shift!', 'success');
await loadShifts();
await loadMySignups();
} else {
showStatus(data.error || 'Failed to sign up', 'error');
}
} catch (error) {
console.error('Error signing up:', error);
showStatus('Failed to sign up for shift', 'error');
}
}
async function cancelSignup(shiftId) {
if (!confirm('Are you sure you want to cancel your signup for this shift?')) {
return;
}
try {
const response = await fetch(`/api/shifts/${shiftId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
showStatus('Signup cancelled', 'success');
await loadShifts();
await loadMySignups();
} else {
showStatus(data.error || 'Failed to cancel signup', 'error');
}
} catch (error) {
console.error('Error cancelling signup:', error);
showStatus('Failed to cancel signup', 'error');
}
}
function setupEventListeners() {
const dateFilter = document.getElementById('date-filter');
if (dateFilter) {
dateFilter.addEventListener('change', filterShifts);
}
}
function filterShifts() {
const dateFilter = document.getElementById('date-filter').value;
if (!dateFilter) {
displayShifts(allShifts);
return;
}
const filtered = allShifts.filter(shift => {
return shift.Date === dateFilter; // Changed from shift.date to shift.Date
});
displayShifts(filtered);
}
function clearFilters() {
document.getElementById('date-filter').value = '';
loadShifts(); // Reload shifts without filters
}
function showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
if (!container) return;
const messageDiv = document.createElement('div');
messageDiv.className = `status-message ${type}`;
messageDiv.textContent = message;
container.appendChild(messageDiv);
setTimeout(() => {
messageDiv.remove();
}, 5000);
}
function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}

View File

@ -0,0 +1,376 @@
// UI interaction handlers
import { showStatus, parseGeoLocation } from './utils.js';
import { map, toggleStartLocationVisibility } from './map-manager.js';
import { loadLocations, handleAddLocation, handleEditLocation, handleDeleteLocation, openEditForm, closeEditForm, closeAddModal, openAddModal } from './location-manager.js';
export let userLocationMarker = null;
export let isAddingLocation = false;
export function getUserLocation() {
if (!navigator.geolocation) {
showStatus('Geolocation is not supported by your browser', 'error');
return;
}
showStatus('Getting your location...', 'info');
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
// Center map on user location
map.setView([lat, lng], 19);
// Add or update user location marker
if (userLocationMarker) {
userLocationMarker.setLatLng([lat, lng]);
} else {
userLocationMarker = L.circleMarker([lat, lng], {
radius: 10,
fillColor: '#2196F3',
color: '#fff',
weight: 3,
opacity: 1,
fillOpacity: 0.8
}).addTo(map);
userLocationMarker.bindPopup('<strong>Your Location</strong>');
}
showStatus('Location found!', 'success');
},
(error) => {
let message = 'Unable to get your location';
switch(error.code) {
case error.PERMISSION_DENIED:
message = 'Location permission denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information unavailable';
break;
case error.TIMEOUT:
message = 'Location request timed out';
break;
}
showStatus(message, 'error');
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
}
export function toggleAddLocationMode() {
isAddingLocation = !isAddingLocation;
const crosshair = document.getElementById('crosshair');
const addBtn = document.getElementById('add-location-btn');
const mobileAddBtn = document.getElementById('mobile-add-location-btn');
if (isAddingLocation) {
crosshair.classList.remove('hidden');
// Update desktop button
if (addBtn) {
addBtn.classList.add('active');
addBtn.innerHTML = '<span class="btn-icon">✕</span><span class="btn-text">Cancel</span>';
}
// Update mobile button
if (mobileAddBtn) {
mobileAddBtn.classList.add('active');
mobileAddBtn.innerHTML = '✕';
mobileAddBtn.title = 'Cancel';
}
map.on('click', handleMapClick);
} else {
crosshair.classList.add('hidden');
// Update desktop button
if (addBtn) {
addBtn.classList.remove('active');
addBtn.innerHTML = '<span class="btn-icon"></span><span class="btn-text">Add Location Here</span>';
}
// Update mobile button
if (mobileAddBtn) {
mobileAddBtn.classList.remove('active');
mobileAddBtn.innerHTML = '';
mobileAddBtn.title = 'Add Location';
}
map.off('click', handleMapClick);
}
}
function handleMapClick(e) {
if (!isAddingLocation) return;
const { lat, lng } = e.latlng;
openAddModal(lat, lng);
toggleAddLocationMode();
}
export function toggleFullscreen() {
const app = document.getElementById('app');
const btn = document.getElementById('fullscreen-btn');
const mobileBtn = document.getElementById('mobile-fullscreen-btn');
if (!document.fullscreenElement) {
app.requestFullscreen().then(() => {
app.classList.add('fullscreen');
// Update desktop button
if (btn) {
btn.innerHTML = '<span class="btn-icon">◱</span><span class="btn-text">Exit Fullscreen</span>';
}
// Update mobile button
if (mobileBtn) {
mobileBtn.innerHTML = '◱';
mobileBtn.title = 'Exit Fullscreen';
}
}).catch(err => {
console.error('Error entering fullscreen:', err);
showStatus('Unable to enter fullscreen', 'error');
});
} else {
document.exitFullscreen().then(() => {
app.classList.remove('fullscreen');
// Update desktop button
if (btn) {
btn.innerHTML = '<span class="btn-icon">⛶</span><span class="btn-text">Fullscreen</span>';
}
// Update mobile button
if (mobileBtn) {
mobileBtn.innerHTML = '⛶';
mobileBtn.title = 'Fullscreen';
}
});
}
}
export async function lookupAddress(mode) {
let latInput, lngInput, addressInput;
if (mode === 'add') {
latInput = document.getElementById('location-lat');
lngInput = document.getElementById('location-lng');
addressInput = document.getElementById('location-address');
} else if (mode === 'edit') {
latInput = document.getElementById('edit-location-lat');
lngInput = document.getElementById('edit-location-lng');
addressInput = document.getElementById('edit-location-address');
} else {
console.error('Invalid lookup mode:', mode);
return;
}
if (!latInput || !lngInput || !addressInput) {
showStatus('Form elements not found', 'error');
return;
}
const lat = parseFloat(latInput.value);
const lng = parseFloat(lngInput.value);
if (isNaN(lat) || isNaN(lng)) {
showStatus('Please enter valid coordinates first', 'warning');
return;
}
// Show loading state
const button = mode === 'add' ?
document.getElementById('lookup-address-add-btn') :
document.getElementById('lookup-address-edit-btn');
const originalText = button ? button.textContent : '';
if (button) {
button.disabled = true;
button.textContent = 'Looking up...';
}
try {
console.log(`Looking up address for: ${lat}, ${lng}`);
const response = await fetch(`/api/geocode/reverse?lat=${lat}&lng=${lng}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Geocoding failed: ${response.status} ${errorText}`);
}
const data = await response.json();
if (data.success && data.data) {
// Use the formatted address or full address
const address = data.data.formattedAddress || data.data.fullAddress;
if (address) {
addressInput.value = address;
showStatus('Address found!', 'success');
} else {
showStatus('No address found for these coordinates', 'warning');
}
} else {
showStatus('Address lookup failed', 'warning');
}
} catch (error) {
console.error('Address lookup error:', error);
showStatus(`Address lookup failed: ${error.message}`, 'error');
} finally {
// Restore button state
if (button) {
button.disabled = false;
button.textContent = originalText;
}
}
}
export function setupGeoLocationSync() {
// For add form
const addLatInput = document.getElementById('location-lat');
const addLngInput = document.getElementById('location-lng');
const addGeoInput = document.getElementById('geo-location');
if (addLatInput && addLngInput && addGeoInput) {
[addLatInput, addLngInput].forEach(input => {
input.addEventListener('input', () => {
const lat = addLatInput.value;
const lng = addLngInput.value;
if (lat && lng) {
addGeoInput.value = `${lat};${lng}`;
}
});
});
addGeoInput.addEventListener('input', () => {
const coords = parseGeoLocation(addGeoInput.value);
if (coords) {
addLatInput.value = coords.lat;
addLngInput.value = coords.lng;
}
});
}
// For edit form
const editLatInput = document.getElementById('edit-location-lat');
const editLngInput = document.getElementById('edit-location-lng');
const editGeoInput = document.getElementById('edit-geo-location');
if (editLatInput && editLngInput && editGeoInput) {
[editLatInput, editLngInput].forEach(input => {
input.addEventListener('input', () => {
const lat = editLatInput.value;
const lng = editLngInput.value;
if (lat && lng) {
editGeoInput.value = `${lat};${lng}`;
}
});
});
editGeoInput.addEventListener('input', () => {
const coords = parseGeoLocation(editGeoInput.value);
if (coords) {
editLatInput.value = coords.lat;
editLngInput.value = coords.lng;
}
});
}
}
// ...existing code...
export function setupEventListeners() {
// Desktop controls
document.getElementById('refresh-btn')?.addEventListener('click', () => {
loadLocations();
showStatus('Locations refreshed', 'success');
});
document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen);
// Mobile controls
document.getElementById('mobile-refresh-btn')?.addEventListener('click', () => {
loadLocations();
showStatus('Locations refreshed', 'success');
});
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
// Mobile dropdown toggle
document.getElementById('mobile-dropdown-toggle')?.addEventListener('click', (e) => {
e.stopPropagation();
const dropdown = document.getElementById('mobile-dropdown');
dropdown.classList.toggle('active');
});
// Close mobile dropdown when clicking outside
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('mobile-dropdown');
if (!dropdown.contains(e.target)) {
dropdown.classList.remove('active');
}
});
// Modal controls
document.getElementById('close-modal-btn')?.addEventListener('click', closeAddModal);
document.getElementById('cancel-modal-btn')?.addEventListener('click', closeAddModal);
// Edit footer controls
document.getElementById('close-edit-footer-btn')?.addEventListener('click', closeEditForm);
// Forms
document.getElementById('location-form')?.addEventListener('submit', handleAddLocation);
document.getElementById('edit-location-form')?.addEventListener('submit', handleEditLocation);
// Delete button
document.getElementById('delete-location-btn')?.addEventListener('click', handleDeleteLocation);
// Address lookup buttons
document.getElementById('lookup-address-add-btn')?.addEventListener('click', () => {
lookupAddress('add');
});
document.getElementById('lookup-address-edit-btn')?.addEventListener('click', () => {
lookupAddress('edit');
});
// Auto address lookup event listener
document.addEventListener('autoAddressLookup', (e) => {
const { mode } = e.detail;
if (mode === 'add') {
// Add a small delay to ensure the form is fully rendered
setTimeout(() => {
lookupAddress('add');
}, 200);
}
});
// Geo-location field sync
setupGeoLocationSync();
// Add event delegation for popup edit buttons
document.addEventListener('click', (e) => {
if (e.target.classList.contains('edit-location-popup-btn')) {
e.preventDefault();
try {
const locationData = JSON.parse(e.target.getAttribute('data-location'));
openEditForm(locationData);
} catch (error) {
console.error('Error parsing location data:', error);
showStatus('Error opening edit form', 'error');
}
}
});
}

View File

@ -0,0 +1,67 @@
// Utility functions
export function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
export function parseGeoLocation(value) {
if (!value) return null;
// Try semicolon separator first
let parts = value.split(';');
if (parts.length !== 2) {
// Try comma separator
parts = value.split(',');
}
if (parts.length === 2) {
const lat = parseFloat(parts[0].trim());
const lng = parseFloat(parts[1].trim());
if (!isNaN(lat) && !isNaN(lng)) {
return { lat, lng };
}
}
return null;
}
export function showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
const messageDiv = document.createElement('div');
messageDiv.className = `status-message ${type}`;
messageDiv.textContent = message;
container.appendChild(messageDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
messageDiv.remove();
}, 5000);
}
export function hideLoading() {
const loading = document.getElementById('loading');
if (loading) {
loading.classList.add('hidden');
}
}
export function updateLocationCount(count) {
const countElement = document.getElementById('location-count');
const mobileCountElement = document.getElementById('mobile-location-count');
const countText = `${count} location${count !== 1 ? 's' : ''}`;
if (countElement) {
countElement.textContent = countText;
}
if (mobileCountElement) {
mobileCountElement.textContent = countText;
}
}

View File

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Login to NocoDB Map Viewer">
<title>Login - NocoDB Map Viewer</title>
<meta name="description" content="Login to Map by BNKops - Interactive canvassing web-app & viewer">
<title>Login - Map by BNKops</title>
<!-- Custom CSS -->
<link rel="stylesheet" href="css/style.css">
@ -163,13 +163,25 @@
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
required
autocomplete="current-password"
>
</div>
<button type="submit" class="login-button" id="login-button">
Sign In
</button>
</form>
<div class="login-footer">
<p>Access is restricted to authorized users only.</p>
<p>Access is restricted to authorized users only. Please contact your system administrator for login details.</p>
</div>
</div>
</div>
@ -180,6 +192,7 @@
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const button = document.getElementById('login-button');
const errorMessage = document.getElementById('error-message');
const successMessage = document.getElementById('success-message');
@ -198,7 +211,7 @@
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
body: JSON.stringify({ email, password }),
credentials: 'include'
});

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Volunteer Shifts - BNKops Map</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/shifts.css">
</head>
<body>
<div id="app">
<header class="header">
<h1>Volunteer Shifts</h1>
<div class="header-actions">
<a href="/" class="btn btn-secondary">← Back to Map</a>
<span id="user-info" class="user-info">
<span class="user-email" id="user-email"></span>
</span>
</div>
</header>
<div class="shifts-container">
<!-- Move My Signups to the top -->
<div class="my-signups">
<h2>My Shifts</h2>
<div id="my-signups-list">
<!-- User's signups will be loaded here -->
</div>
</div>
<div class="shifts-filters">
<h2>Available Shifts</h2>
<div class="filter-group">
<label for="date-filter">Filter by Date:</label>
<input type="date" id="date-filter" />
<button class="btn btn-secondary btn-sm" id="clear-filters-btn">Clear</button>
</div>
</div>
<div class="shifts-grid" id="shifts-grid">
<!-- Shifts will be loaded here -->
</div>
</div>
<div id="status-container" class="status-container"></div>
</div>
<script src="js/shifts.js"></script>
</body>
</html>

13
map/app/routes/admin.js Normal file
View File

@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const settingsController = require('../controllers/settingsController');
// Start location management
router.get('/start-location', settingsController.getStartLocation);
router.post('/start-location', settingsController.updateStartLocation);
// Walk sheet configuration
router.get('/walk-sheet-config', settingsController.getWalkSheetConfig);
router.post('/walk-sheet-config', settingsController.updateWalkSheetConfig);
module.exports = router;

15
map/app/routes/auth.js Normal file
View File

@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const { authLimiter } = require('../middleware/rateLimiter');
// Login route with rate limiting
router.post('/login', authLimiter, authController.login);
// Logout route
router.post('/logout', authController.logout);
// Check authentication status
router.get('/check', authController.check);
module.exports = router;

225
map/app/routes/debug.js Normal file
View File

@ -0,0 +1,225 @@
const express = require('express');
const router = express.Router();
const nocodbService = require('../services/nocodb');
const config = require('../config');
const logger = require('../utils/logger');
const { generateQRCode } = require('../services/qrcode');
// Debug session endpoint
router.get('/session', (req, res) => {
res.json({
sessionID: req.sessionID,
session: req.session,
cookies: req.cookies,
authenticated: req.session?.authenticated || false
});
});
// Check table structure
router.get('/table-structure', async (req, res) => {
try {
const response = await nocodbService.getAll(config.nocodb.tableId, {
limit: 1
});
const sample = response.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
router.get('/test-qr', 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
});
}
});
// Walk sheet configuration debug
router.get('/walk-sheet-config', async (req, res) => {
try {
const debugInfo = {
settingsSheetId: config.nocodb.settingsSheetId,
settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
hasSettingsSheet: !!config.nocodb.settingsSheetId,
timestamp: new Date().toISOString()
};
if (!config.nocodb.settingsSheetId) {
return res.json({
success: true,
debug: debugInfo,
message: 'Settings sheet not configured'
});
}
// Test connection to settings sheet
const response = await nocodbService.getAll(config.nocodb.settingsSheetId, {
limit: 5,
sort: '-created_at'
});
const records = response.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: config.nocodb.settingsSheetId,
settingsSheetConfigured: process.env.NOCODB_SETTINGS_SHEET,
hasSettingsSheet: !!config.nocodb.settingsSheetId,
timestamp: new Date().toISOString(),
error: error.message,
errorDetails: error.response?.data
}
});
}
});
// Test walk sheet save
router.post('/test-walk-sheet-save', 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...');
if (!config.nocodb.settingsSheetId) {
return res.json({
success: false,
test: 'failed',
error: 'Settings sheet not configured',
config: testConfig
});
}
const walkSheetData = {
created_at: new Date().toISOString(),
created_by: req.session.userEmail,
...testConfig
};
const response = await nocodbService.create(
config.nocodb.settingsSheetId,
walkSheetData
);
res.json({
success: true,
test: 'passed',
message: 'Test walk sheet configuration saved successfully',
testData: walkSheetData,
saveResponse: response,
settingsId: response.id || response.Id || response.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()
});
}
});
// Raw walk sheet data
router.get('/walk-sheet-raw', async (req, res) => {
try {
if (!config.nocodb.settingsSheetId) {
return res.json({ error: 'No settings sheet ID configured' });
}
const response = await nocodbService.getAll(config.nocodb.settingsSheetId, {
sort: '-created_at',
limit: 5
});
return res.json({
success: true,
tableId: config.nocodb.settingsSheetId,
records: response.list || [],
count: response.list?.length || 0
});
} catch (error) {
logger.error('Error fetching raw walk sheet data:', error);
return res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;

109
map/app/routes/index.js Normal file
View File

@ -0,0 +1,109 @@
const express = require('express');
const path = require('path');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// Import route modules
const authRoutes = require('./auth');
const locationRoutes = require('./locations');
const adminRoutes = require('./admin');
const settingsRoutes = require('./settings');
const userRoutes = require('./users');
const qrRoutes = require('./qr');
const debugRoutes = require('./debug');
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
const shiftsRoutes = require('./shifts');
module.exports = (app) => {
// Health check (no auth)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0'
});
});
// Login page (no auth)
app.get('/login.html', (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'login.html'));
});
// Auth routes (no auth required)
app.use('/api/auth', authRoutes);
// Public config endpoint
app.get('/api/config/start-location', require('../controllers/settingsController').getPublicStartLocation);
// QR code routes (authenticated)
app.use('/api/qr', requireAuth, qrRoutes);
// Test QR page (no auth for testing)
app.get('/test-qr', (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'test-qr.html'));
});
// Protected routes
app.use('/api/locations', requireAuth, locationRoutes);
app.use('/api/geocode', requireAuth, geocodingRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api/shifts', shiftsRoutes);
// Admin routes
app.get('/admin.html', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
});
app.use('/api/admin', requireAdmin, adminRoutes);
app.use('/api/users', requireAdmin, userRoutes);
// Debug routes (admin only)
app.use('/api/debug', requireAdmin, debugRoutes);
// Config check endpoint (authenticated)
app.get('/api/config-check', requireAuth, (req, res) => {
const config = require('../config');
const configStatus = {
hasApiUrl: !!config.nocodb.apiUrl,
hasApiToken: !!config.nocodb.apiToken,
hasProjectId: !!config.nocodb.projectId,
hasTableId: !!config.nocodb.tableId,
hasLoginSheet: !!config.nocodb.loginSheetId,
hasSettingsSheet: !!config.nocodb.settingsSheetId,
projectId: config.nocodb.projectId,
tableId: config.nocodb.tableId,
loginSheet: config.nocodb.loginSheetId,
settingsSheet: config.nocodb.settingsSheetId,
nodeEnv: config.nodeEnv
};
const isConfigured = configStatus.hasApiUrl &&
configStatus.hasApiToken &&
configStatus.hasProjectId &&
configStatus.hasTableId;
res.json({
configured: isConfigured,
...configStatus
});
});
// Serve static files (protected)
app.use(express.static(path.join(__dirname, '../public'), {
index: false // Don't serve index.html automatically
}));
// Main app route (protected)
app.get('/', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'index.html'));
});
// Protected page route
app.get('/shifts.html', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'shifts.html'));
});
// Catch all - redirect to login
app.get('*', (req, res) => {
res.redirect('/login.html');
});
};

View File

@ -0,0 +1,21 @@
const express = require('express');
const router = express.Router();
const locationsController = require('../controllers/locationsController');
const { strictLimiter } = require('../middleware/rateLimiter');
// Get all locations
router.get('/', locationsController.getAll);
// Get single location
router.get('/:id', locationsController.getById);
// Create location (with rate limiting)
router.post('/', strictLimiter, locationsController.create);
// Update location (with rate limiting)
router.put('/:id', strictLimiter, locationsController.update);
// Delete location (with rate limiting)
router.delete('/:id', strictLimiter, locationsController.delete);
module.exports = router;

48
map/app/routes/qr.js Normal file
View File

@ -0,0 +1,48 @@
const express = require('express');
const router = express.Router();
const logger = require('../utils/logger');
const { generateQRCode } = require('../services/qrcode');
// Generate QR code
router.get('/', 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 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'
});
}
});
module.exports = router;

View File

@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const settingsController = require('../controllers/settingsController');
// Get current settings
router.get('/start-location', settingsController.getStartLocation);
router.get('/walk-sheet', settingsController.getWalkSheetConfig);
// Update settings (POST routes)
router.post('/start-location', settingsController.updateStartLocation);
router.post('/walk-sheet', settingsController.updateWalkSheetConfig);
module.exports = router;

18
map/app/routes/shifts.js Normal file
View File

@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const shiftsController = require('../controllers/shiftsController');
const { requireAuth, requireAdmin } = require('../middleware/auth');
// Public routes (authenticated users)
router.get('/', requireAuth, shiftsController.getAll);
router.get('/my-signups', requireAuth, shiftsController.getUserSignups);
router.post('/:shiftId/signup', requireAuth, shiftsController.signup);
router.post('/:shiftId/cancel', requireAuth, shiftsController.cancelSignup);
// Admin routes
router.get('/admin', requireAdmin, shiftsController.getAllAdmin);
router.post('/admin', requireAdmin, shiftsController.create);
router.put('/admin/:id', requireAdmin, shiftsController.update);
router.delete('/admin/:id', requireAdmin, shiftsController.delete);
module.exports = router;

14
map/app/routes/users.js Normal file
View File

@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
// Get all users
router.get('/', usersController.getAll);
// Create new user
router.post('/', usersController.create);
// Delete user
router.delete('/:id', usersController.delete);
module.exports = router;

2051
map/app/server copy.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,150 +1,36 @@
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 cors = require('cors');
const session = require('express-session');
const cookieParser = require('cookie-parser');
require('dotenv').config();
const crypto = require('crypto');
// Import geocoding routes
const geocodingRoutes = require('./routes/geocoding');
// 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}`; // Use semicolon format for NocoDB GeoData
data.geodata = `${lat};${lng}`; // Also update geodata for compatibility
}
}
// 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}`);
}
}
// 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()
})
]
});
// Import configuration and utilities
const config = require('./config');
const logger = require('./utils/logger');
const { getCookieConfig } = require('./utils/helpers');
const { apiLimiter } = require('./middleware/rateLimiter');
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Trust proxy for Cloudflare
app.set('trust proxy', true);
// Cookie parser
app.use(cookieParser());
// Session configuration
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production',
secret: config.session.secret,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Enable for HTTPS
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax',
domain: process.env.COOKIE_DOMAIN || undefined // Add domain support
},
name: 'nocodb-map-session'
cookie: getCookieConfig(),
name: 'nocodb-map-session',
genid: (req) => {
// Use a custom session ID generator to avoid conflicts
return crypto.randomBytes(16).toString('hex');
}
}));
// Security middleware
@ -153,8 +39,8 @@ app.use(helmet({
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://unpkg.com"],
connectSrc: ["'self'"]
}
}
@ -166,7 +52,7 @@ app.use(cors({
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
const allowedOrigins = config.cors.allowedOrigins;
if (allowedOrigins.includes(origin) || allowedOrigins.includes('*')) {
callback(null, true);
} else {
@ -178,585 +64,42 @@ app.use(cors({
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
// Body parser 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');
}
}
};
// 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
req.session.authenticated = true;
req.session.userEmail = email;
req.session.userName = authorizedUser.Name || email;
// 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}`);
res.json({
success: true,
message: 'Login successful',
user: {
email: email,
name: req.session.userName
}
});
});
} 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
} : 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'
});
});
});
// Add this after the /api/auth/check route
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);
app.use('/api/', apiLimiter);
// 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);
}
}
// Try to parse from Geo-Location column (semicolon-separated first, then comma)
if (loc['Geo-Location'] && typeof loc['Geo-Location'] === 'string') {
// Try semicolon first (as we see in the data)
let parts = loc['Geo-Location'].split(';');
if (parts.length === 2) {
loc.latitude = parseFloat(parts[0].trim());
loc.longitude = parseFloat(parts[1].trim());
if (!isNaN(loc.latitude) && !isNaN(loc.longitude)) {
return true;
}
}
// Fallback to comma-separated
parts = loc['Geo-Location'].split(',');
if (parts.length === 2) {
loc.latitude = parseFloat(parts[0].trim());
loc.longitude = parseFloat(parts[1].trim());
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.updated_at = new Date().toISOString();
updateData.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'
});
}
});
// Import and setup routes
require('./routes')(app);
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({
// Don't leak error details in production
const message = config.isProduction ?
'Internal server error' :
err.message || 'Internal server error';
res.status(err.status || 500).json({
success: false,
error: 'Internal server error'
error: message
});
});
// Start server
app.listen(PORT, () => {
const server = app.listen(config.port, () => {
logger.info(`
NocoDB Map Viewer Server
BNKops Map Server
Status: Running
Port: ${PORT}
Environment: ${process.env.NODE_ENV || 'development'}
Project ID: ${process.env.NOCODB_PROJECT_ID}
Table ID: ${process.env.NOCODB_TABLE_ID}
Login Sheet: ${LOGIN_SHEET_ID || 'Not Configured'}
Port: ${config.port}
Environment: ${config.nodeEnv}
Project ID: ${config.nocodb.projectId}
Table ID: ${config.nocodb.tableId}
Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'}
Time: ${new Date().toISOString()}
`);
@ -765,9 +108,30 @@ app.listen(PORT, () => {
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
app.close(() => {
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT signal received: closing HTTP server');
server.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception:', err);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
module.exports = app;

143
map/app/services/nocodb.js Normal file
View File

@ -0,0 +1,143 @@
const axios = require('axios');
const config = require('../config');
const logger = require('../utils/logger');
class NocoDBService {
constructor() {
this.apiUrl = config.nocodb.apiUrl;
this.apiToken = config.nocodb.apiToken;
this.projectId = config.nocodb.projectId;
this.timeout = 10000; // 10 seconds
// Create axios instance with defaults
this.client = axios.create({
baseURL: this.apiUrl,
timeout: this.timeout,
headers: {
'xc-token': this.apiToken,
'Content-Type': 'application/json'
}
});
// Add response interceptor for error handling
this.client.interceptors.response.use(
response => response,
error => {
logger.error('NocoDB API Error:', {
message: error.message,
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data
});
throw error;
}
);
}
// Build table URL
getTableUrl(tableId) {
return `/db/data/v1/${this.projectId}/${tableId}`;
}
// Get all records from a table
async getAll(tableId, params = {}) {
const url = this.getTableUrl(tableId);
const response = await this.client.get(url, { params });
return response.data;
}
// Get single record
async getById(tableId, recordId) {
const url = `${this.getTableUrl(tableId)}/${recordId}`;
const response = await this.client.get(url);
return response.data;
}
// Create record
async create(tableId, data) {
const url = this.getTableUrl(tableId);
const response = await this.client.post(url, data);
return response.data;
}
// Update record
async update(tableId, recordId, data) {
const url = `${this.getTableUrl(tableId)}/${recordId}`;
const response = await this.client.patch(url, data);
return response.data;
}
// Delete record
async delete(tableId, recordId) {
const url = `${this.getTableUrl(tableId)}/${recordId}`;
const response = await this.client.delete(url);
return response.data;
}
// Get locations with proper filtering
async getLocations(params = {}) {
const defaultParams = {
limit: 1000,
offset: 0,
...params
};
return this.getAll(config.nocodb.tableId, defaultParams);
}
// Get user by email
async getUserByEmail(email) {
if (!config.nocodb.loginSheetId) {
throw new Error('Login sheet not configured');
}
const response = await this.getAll(config.nocodb.loginSheetId, {
where: `(Email,eq,${email})`,
limit: 1
});
return response.list?.[0] || null;
}
// Get latest settings
async getLatestSettings() {
if (!config.nocodb.settingsSheetId) {
return null;
}
const response = await this.getAll(config.nocodb.settingsSheetId, {
sort: '-created_at',
limit: 1
});
return response.list?.[0] || null;
}
// Get settings with walk sheet data
async getWalkSheetSettings() {
if (!config.nocodb.settingsSheetId) {
return null;
}
const response = await this.getAll(config.nocodb.settingsSheetId, {
sort: '-created_at',
limit: 20
});
// Find first row with walk sheet data
const settings = response.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.list?.[0];
return settings || null;
}
}
// Export singleton instance
module.exports = new NocoDBService();

162
map/app/services/qrcode.js Normal file
View File

@ -0,0 +1,162 @@
const QRCode = require('qrcode');
const axios = require('axios');
const FormData = require('form-data');
const winston = require('winston');
// 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()
})
]
});
/**
* Generate QR code as PNG buffer
* @param {string} text - Text/URL to encode
* @param {Object} options - QR code options
* @returns {Promise<Buffer>} PNG buffer
*/
async function generateQRCode(text, options = {}) {
const defaultOptions = {
type: 'png',
width: 256,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
};
const qrOptions = { ...defaultOptions, ...options };
try {
const buffer = await QRCode.toBuffer(text, qrOptions);
return buffer;
} catch (error) {
logger.error('Failed to generate QR code:', error);
throw new Error('Failed to generate QR code');
}
}
/**
* Upload QR code to NocoDB storage
* @param {Buffer} buffer - PNG buffer
* @param {string} filename - Filename for the upload
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload response
*/
async function uploadQRCodeToNocoDB(buffer, filename, config) {
const formData = new FormData();
formData.append('file', buffer, {
filename: filename,
contentType: 'image/png'
});
try {
// Use the base URL without /api/v1 for v2 endpoints
const baseUrl = config.apiUrl.replace('/api/v1', '');
const uploadUrl = `${baseUrl}/api/v2/storage/upload`;
logger.info(`Uploading QR code to: ${uploadUrl}`);
const response = await axios({
url: uploadUrl,
method: 'post',
data: formData,
headers: {
...formData.getHeaders(),
'xc-token': config.apiToken
},
params: {
path: 'qrcodes'
}
});
logger.info('QR code upload successful:', response.data);
return response.data;
} catch (error) {
logger.error('Failed to upload QR code to NocoDB:', error.response?.data || error.message);
throw new Error('Failed to upload QR code');
}
}
/**
* Generate and upload QR code
* @param {string} url - URL to encode
* @param {string} label - Label for the QR code
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload result
*/
async function generateAndUploadQRCode(url, label, config) {
if (!url) {
return null;
}
try {
// Generate QR code
const buffer = await generateQRCode(url);
// Create filename
const timestamp = Date.now();
const safeLabel = label.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const filename = `qr_${safeLabel}_${timestamp}.png`;
// Upload to NocoDB
const uploadResult = await uploadQRCodeToNocoDB(buffer, filename, config);
return uploadResult;
} catch (error) {
logger.error('Failed to generate and upload QR code:', error);
throw error;
}
}
/**
* Delete QR code from NocoDB storage
* @param {string} fileUrl - File URL to delete
* @param {Object} config - NocoDB configuration
* @returns {Promise<boolean>} Success status
*/
async function deleteQRCodeFromNocoDB(fileUrl, config) {
if (!fileUrl) {
return true;
}
try {
// Extract file path from URL
const urlParts = fileUrl.split('/');
const filePath = urlParts.slice(-2).join('/');
await axios({
url: `${config.apiUrl}/api/v2/storage/upload`,
method: 'delete',
headers: {
'xc-token': config.apiToken
},
params: {
path: filePath
}
});
return true;
} catch (error) {
logger.error('Failed to delete QR code from NocoDB:', error);
// Don't throw error for deletion failures
return false;
}
}
module.exports = {
generateQRCode,
uploadQRCodeToNocoDB,
generateAndUploadQRCode,
deleteQRCodeFromNocoDB
};

184
map/app/utils/helpers.js Normal file
View File

@ -0,0 +1,184 @@
// Sync geographic fields between different formats
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;
}
// Validate URL format
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;
}
return '';
}
}
// Get cookie configuration based on request
function 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,
domain: undefined
};
// Only set domain and secure for production non-localhost access
if (process.env.NODE_ENV === 'production' && !isLocalhost && process.env.COOKIE_DOMAIN) {
const cookieDomain = process.env.COOKIE_DOMAIN.replace(/^\./, '');
if (host.includes(cookieDomain)) {
config.domain = process.env.COOKIE_DOMAIN;
config.secure = true;
}
}
return config;
}
// Extract ID from NocoDB response
function extractId(record) {
return record.Id || record.id || record.ID || record._id;
}
// Validate coordinates
function validateCoordinates(lat, lng) {
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
if (isNaN(latitude) || isNaN(longitude)) {
return { valid: false, error: 'Invalid coordinate values' };
}
if (latitude < -90 || latitude > 90) {
return { valid: false, error: 'Latitude must be between -90 and 90' };
}
if (longitude < -180 || longitude > 180) {
return { valid: false, error: 'Longitude must be between -180 and 180' };
}
return { valid: true, latitude, longitude };
}
// Check if coordinates are within bounds
function checkBounds(lat, lng, bounds) {
if (!bounds) return true;
return lat <= bounds.north &&
lat >= bounds.south &&
lng <= bounds.east &&
lng >= bounds.west;
}
// Sanitize user data for response
function sanitizeUser(user) {
const { Password, password, ...safeUser } = user;
return safeUser;
}
// Extract walk sheet configuration from NocoDB data, handling different field name formats
function extractWalkSheetConfig(data, defaults = {}) {
if (!data) return defaults;
return {
walk_sheet_title: data.walk_sheet_title !== undefined ? data.walk_sheet_title :
data['Walk Sheet Title'] !== undefined ? data['Walk Sheet Title'] :
defaults.walk_sheet_title,
walk_sheet_subtitle: data.walk_sheet_subtitle !== undefined ? data.walk_sheet_subtitle :
data['Walk Sheet Subtitle'] !== undefined ? data['Walk Sheet Subtitle'] :
defaults.walk_sheet_subtitle,
walk_sheet_footer: data.walk_sheet_footer !== undefined ? data.walk_sheet_footer :
data['Walk Sheet Footer'] !== undefined ? data['Walk Sheet Footer'] :
defaults.walk_sheet_footer,
qr_code_1_url: data.qr_code_1_url !== undefined ? data.qr_code_1_url :
data['QR Code 1 URL'] !== undefined ? data['QR Code 1 URL'] :
defaults.qr_code_1_url,
qr_code_1_label: data.qr_code_1_label !== undefined ? data.qr_code_1_label :
data['QR Code 1 Label'] !== undefined ? data['QR Code 1 Label'] :
defaults.qr_code_1_label,
qr_code_2_url: data.qr_code_2_url !== undefined ? data.qr_code_2_url :
data['QR Code 2 URL'] !== undefined ? data['QR Code 2 URL'] :
defaults.qr_code_2_url,
qr_code_2_label: data.qr_code_2_label !== undefined ? data.qr_code_2_label :
data['QR Code 2 Label'] !== undefined ? data['QR Code 2 Label'] :
defaults.qr_code_2_label,
qr_code_3_url: data.qr_code_3_url !== undefined ? data.qr_code_3_url :
data['QR Code 3 URL'] !== undefined ? data['QR Code 3 URL'] :
defaults.qr_code_3_url,
qr_code_3_label: data.qr_code_3_label !== undefined ? data.qr_code_3_label :
data['QR Code 3 Label'] !== undefined ? data['QR Code 3 Label'] :
defaults.qr_code_3_label
};
}
module.exports = {
syncGeoFields,
validateUrl,
getCookieConfig,
extractId,
validateCoordinates,
checkBounds,
sanitizeUser,
extractWalkSheetConfig
};

33
map/app/utils/logger.js Normal file
View File

@ -0,0 +1,33 @@
const winston = require('winston');
const config = require('../config');
const logger = winston.createLogger({
level: config.isProduction ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'bnkops-map' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
]
});
// Add file transport in production
if (config.isProduction) {
logger.add(new winston.transports.File({
filename: 'error.log',
level: 'error'
}));
logger.add(new winston.transports.File({
filename: 'combined.log'
}));
}
module.exports = logger;

441
map/build-nocodb.md Normal file
View File

@ -0,0 +1,441 @@
# NocoDB Automation Script Development Summary
## Overview
This document summarizes the development of an automated NocoDB table creation script (`build-nocodb.sh`) for the Map Viewer project. The script automates the creation of three required tables: `locations`, `login`, and `settings` with proper schemas and default data.
## Project Requirements
Based on the README.md analysis, the project needed:
- **locations** table: Main map data storage
- **login** table: User authentication
- **settings** table: System configuration and QR codes
- Default admin user and start location records
- Idempotent script (safe to re-run)
## NocoDB API Research
### API Versions
- **v1 API**: `/api/v1/` - Legacy, limited functionality
- **v2 API**: `/api/v2/` - Modern, full-featured (recommended)
### Key API Endpoints Discovered
#### Base/Project Management
```
GET /api/v2/meta/bases # List all bases
POST /api/v2/meta/bases # Create new base
GET /api/v2/meta/bases/{id} # Get base details
```
#### Table Management
```
GET /api/v2/meta/bases/{base_id}/tables # List tables in base
POST /api/v2/meta/bases/{base_id}/tables # Create table
GET /api/v2/meta/bases/{base_id}/tables/{table_id} # Get table details
```
#### Record Management
```
GET /api/v2/tables/{table_id}/records # List records
POST /api/v2/tables/{table_id}/records # Create record
PUT /api/v2/tables/{table_id}/records/{record_id} # Update record
```
### Authentication
All API calls require the `xc-token` header:
```bash
curl -H "xc-token: YOUR_TOKEN" -H "Content-Type: application/json"
```
## Table Schemas Implemented
### 1. Locations Table
Primary table for map data storage:
```json
{
"table_name": "locations",
"columns": [
{"column_name": "id", "uidt": "ID", "pk": true, "ai": true},
{"column_name": "title", "uidt": "SingleLineText"},
{"column_name": "description", "uidt": "LongText"},
{"column_name": "category", "uidt": "SingleSelect", "colOptions": {
"options": [
{"title": "Important", "color": "#ff0000"},
{"title": "Event", "color": "#00ff00"},
{"title": "Business", "color": "#0000ff"},
{"title": "Other", "color": "#ffff00"}
]
}},
{"column_name": "geo_location", "uidt": "LongText"},
{"column_name": "latitude", "uidt": "Decimal"},
{"column_name": "longitude", "uidt": "Decimal"},
{"column_name": "address", "uidt": "LongText"},
{"column_name": "contact_info", "uidt": "LongText"},
{"column_name": "created_at", "uidt": "DateTime"},
{"column_name": "updated_at", "uidt": "DateTime"}
]
}
```
### 2. Login Table
User authentication table:
```json
{
"table_name": "login",
"columns": [
{"column_name": "id", "uidt": "ID", "pk": true, "ai": true},
{"column_name": "username", "uidt": "SingleLineText", "rqd": true},
{"column_name": "email", "uidt": "Email", "rqd": true},
{"column_name": "password", "uidt": "SingleLineText", "rqd": true},
{"column_name": "admin", "uidt": "Checkbox"},
{"column_name": "active", "uidt": "Checkbox"},
{"column_name": "created_at", "uidt": "DateTime"},
{"column_name": "last_login", "uidt": "DateTime"}
]
}
```
### 3. Settings Table
System configuration with QR code support:
```json
{
"table_name": "settings",
"columns": [
{"column_name": "id", "uidt": "ID", "pk": true, "ai": true},
{"column_name": "key", "uidt": "SingleLineText", "rqd": true},
{"column_name": "title", "uidt": "SingleLineText"},
{"column_name": "geo_location", "uidt": "LongText"},
{"column_name": "latitude", "uidt": "Decimal"},
{"column_name": "longitude", "uidt": "Decimal"},
{"column_name": "zoom", "uidt": "Number"},
{"column_name": "category", "uidt": "SingleSelect", "colOptions": {
"options": [
{"title": "system_setting", "color": "#4CAF50"},
{"title": "user_setting", "color": "#2196F3"},
{"title": "app_config", "color": "#FF9800"}
]
}},
{"column_name": "updated_by", "uidt": "SingleLineText"},
{"column_name": "updated_at", "uidt": "DateTime"},
{"column_name": "qr_code_1_url", "uidt": "URL"},
{"column_name": "qr_code_1_label", "uidt": "SingleLineText"},
{"column_name": "qr_code_1_image", "uidt": "Attachment"},
{"column_name": "qr_code_2_url", "uidt": "URL"},
{"column_name": "qr_code_2_label", "uidt": "SingleLineText"},
{"column_name": "qr_code_2_image", "uidt": "Attachment"},
{"column_name": "qr_code_3_url", "uidt": "URL"},
{"column_name": "qr_code_3_label", "uidt": "SingleLineText"},
{"column_name": "qr_code_3_image", "uidt": "Attachment"}
]
}
```
## NocoDB Column Types (UIdt)
Discovered column types and their usage:
- `ID` - Auto-incrementing primary key
- `SingleLineText` - Short text field
- `LongText` - Multi-line text area
- `Email` - Email validation
- `URL` - URL validation
- `Decimal` - Decimal numbers
- `Number` - Integer numbers
- `DateTime` - Date and time
- `Checkbox` - Boolean true/false
- `SingleSelect` - Dropdown with predefined options
- `Attachment` - File upload field
## Script Development Process
### Initial Implementation
1. Created basic structure with environment variable loading
2. Implemented API connectivity testing
3. Added base/project creation functionality
4. Created table creation functions
### Key Challenges Solved
#### 1. Environment Variable Loading
**Issue**: Standard `source .env` wasn't exporting variables
**Solution**: Use `set -a; source .env; set +a` pattern
```bash
set -a # Auto-export all variables
source .env # Load environment file
set +a # Disable auto-export
```
#### 2. API Version Compatibility
**Issue**: Mixed v1/v2 endpoint usage causing errors
**Solution**: Standardized on v2 API with proper URL construction
```bash
BASE_URL=$(echo "$NOCODB_API_URL" | sed 's|/api/v1||')
API_BASE_V2="${BASE_URL}/api/v2"
```
#### 3. Duplicate Table Error
**Issue**: Script failed when tables already existed
**Solution**: Added idempotent table checking
```bash
get_table_id_by_name() {
local base_id=$1
local table_name=$2
# Check if table exists by name
local tables_response
tables_response=$(make_api_call "GET" "/meta/bases/$base_id/tables" "" "Fetching tables")
# Parse JSON to find table ID
local table_id
table_id=$(echo "$tables_response" | grep -o '"id":"[^"]*","table_name":"'"$table_name"'"' | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"//;s/"//')
if [ -n "$table_id" ]; then
echo "$table_id"
return 0
else
return 1
fi
}
```
#### 4. JSON Response Parsing
**Issue**: Complex JSON parsing for table IDs
**Solution**: Used grep with regex patterns
```bash
# Extract table ID from JSON response
table_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
```
## Default Data Records
### Admin User
```json
{
"username": "admin",
"email": "admin@example.com",
"password": "changeme123",
"admin": true,
"active": true,
"created_at": "2025-07-05 12:00:00"
}
```
### Start Location Setting
```json
{
"key": "start_location",
"title": "Map Start Location",
"geo_location": "53.5461;-113.4938",
"latitude": 53.5461,
"longitude": -113.4938,
"zoom": 11,
"category": "system_setting",
"updated_by": "system",
"updated_at": "2025-07-05 12:00:00"
}
```
## Error Handling Patterns
### API Call Wrapper
```bash
make_api_call() {
local method=$1
local endpoint=$2
local data=$3
local description=$4
local api_version=${5:-"v2"}
# Construct full URL
if [[ "$api_version" == "v1" ]]; then
full_url="$API_BASE_V1$endpoint"
else
full_url="$API_BASE_V2$endpoint"
fi
# Make request with timeout
response=$(curl -s -w "%{http_code}" -X "$method" \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
--max-time 30 \
-d "$data" \
"$full_url" 2>/dev/null)
# Parse HTTP code and response
http_code="${response: -3}"
response_body="${response%???}"
# Check for success
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
echo "$response_body"
return 0
else
print_error "API call failed: $http_code - $response_body"
return 1
fi
}
```
## Final Script Features
### Idempotent Operation
- Checks for existing base/project
- Validates table existence before creation
- Uses existing table IDs when found
- Safe to run multiple times
### Robust Error Handling
- Network timeout protection
- HTTP status code validation
- JSON parsing error handling
- Colored output for status messages
### Environment Integration
- Loads configuration from `.env` file
- Supports custom default coordinates
- Validates required variables
## Usage Instructions
1. **Setup Environment**:
```bash
# Update .env with your NocoDB details
NOCODB_API_URL=https://your-nocodb.com/api/v1
NOCODB_API_TOKEN=your_token_here
```
2. **Run Script**:
```bash
chmod +x build-nocodb.sh
./build-nocodb.sh
```
3. **Post-Setup**:
- Update `.env` with generated table URLs
- Change default admin password
- Verify tables in NocoDB interface
## Lessons Learned
1. **API Documentation**: Always verify API endpoints with actual testing
2. **JSON Parsing**: Shell-based JSON parsing requires careful regex patterns
3. **Idempotency**: Essential for automation scripts in production
4. **Error Handling**: Comprehensive error handling prevents silent failures
5. **Environment Variables**: Proper loading patterns are crucial for script reliability
## Future Enhancements
- Add support for custom table schemas via configuration
- Implement data migration features
- Add backup/restore functionality
- Support for multiple environment configurations
- Integration with CI/CD pipelines
## Script Updates - July 2025
### Column Type Improvements
Updated the build-nocodb.sh script to use proper NocoDB column types based on the official documentation:
#### Locations Table Updates
- **`geo_location`**: Changed from `LongText` to `GeoData` (proper geographic data type)
- **`latitude`**: Added precision (10) and scale (8) for proper decimal handling
- **`longitude`**: Added precision (11) and scale (8) for proper decimal handling
- **`phone`**: Changed from `SingleLineText` to `PhoneNumber` (proper phone validation)
- **`email`**: Using `Email` type for proper email validation
- **Updated field names**: Added proper fields from README.md:
- `first_name`, `last_name` (SingleLineText)
- `unit_number` (SingleLineText)
- `support_level` (SingleSelect with colors: 1=Green, 2=Yellow, 3=Orange, 4=Red)
- `sign` (Checkbox)
- `sign_size` (SingleSelect: Small, Medium, Large)
- `notes` (LongText)
- `address` (SingleLineText instead of LongText)
#### Login Table Updates
- **Simplified structure**: Removed username/password fields per README.md specification
- **Core fields**: `email` (Email), `name` (SingleLineText), `admin` (Checkbox)
- **Authentication note**: This is a simplified table - proper authentication should be implemented separately
#### Settings Table Updates
- **`geo_location`**: Changed from `LongText` to `GeoData` for proper geographic data handling
- **`latitude`/`longitude`**: Added precision and scale parameters
- **`value`**: Added missing `value` field from README.md specification
- **QR Code fields**: Simplified to just attachment fields (removed URL/label fields not in README.md)
### Benefits of Proper Column Types
1. **GeoData Type**:
- Proper latitude;longitude format validation
- Better integration with mapping libraries
- Consistent data storage format
2. **PhoneNumber Type**:
- Built-in phone number validation
- Proper formatting and display
- International number support
3. **Email Type**:
- Email format validation
- Prevents invalid email addresses
- Better UI experience
4. **Decimal Precision**:
- Latitude: 10 digits, 8 decimal places (±90.12345678)
- Longitude: 11 digits, 8 decimal places (±180.12345678)
- Provides GPS-level precision for mapping
5. **SingleSelect with Colors**:
- Support Level: Color-coded options for visual feedback
- Sign Size: Consistent option selection
- Category: Organized classification system
### Backward Compatibility
The script maintains backward compatibility while using proper column types. Existing data migration may be needed if upgrading from the old schema.
## Walk Sheet Implementation Overhaul - July 2025
### Overview
The walk sheet system has been completely overhauled to simplify QR code handling and improve mobile usability. The new approach stores only text configuration and generates QR codes on-demand.
### Key Changes Made
#### 1. Database Schema Simplification
- **Removed**: `qr_code_1_image`, `qr_code_2_image`, `qr_code_3_image` attachment fields
- **Kept**: Only text fields for URLs and labels:
- `walk_sheet_title`, `walk_sheet_subtitle`, `walk_sheet_footer`
- `qr_code_1_url`, `qr_code_1_label`
- `qr_code_2_url`, `qr_code_2_label`
- `qr_code_3_url`, `qr_code_3_label`
#### 2. Backend API Updates
- **GET `/api/admin/walk-sheet-config`**: Returns only text configuration
- **POST `/api/admin/walk-sheet-config`**: Saves only text fields
- **Removed**: All QR code upload/storage logic
- **Kept**: Local QR generation via `/api/qr` endpoint for preview/print
#### 3. Frontend Improvements
- **Simplified JavaScript**: Removed `storedQRCodes` logic and image upload handling
- **Better Mobile Support**: Responsive layout with stacked preview on mobile
- **Larger Preview**: Increased from 50% to 75% scale on desktop
- **Real-time Preview**: QR codes generated on-the-fly using canvas
#### 4. CSS Redesign
- **Desktop**: 40/60 split (config/preview) for better preview visibility
- **Mobile**: Stacked layout with horizontal scroll for preview
- **Improved Scaling**: Better touch targets and spacing
- **Professional Styling**: Enhanced typography and visual hierarchy
### Benefits of New Approach
1. **Simpler**: No file storage complexity
2. **Faster**: No upload/download of images
3. **Flexible**: QR codes always reflect current URLs
4. **Cleaner**: Database only stores configuration text
5. **Scalable**: No storage concerns for QR images
6. **Mobile-Friendly**: Better responsive design
### Migration Notes
- Existing QR image data can be ignored (will be regenerated)
- Text configuration will be preserved
- No data loss as QR codes are generated from URLs
- Safe to run build script multiple times
---
*Generated: July 5, 2025*
*Script Version: Column Type Optimized*

856
map/build-nocodb.sh Executable file
View File

@ -0,0 +1,856 @@
#!/bin/bash
# NocoDB Auto-Setup Script
# This script automatically creates the necessary base and tables for the BNKops Map Viewer application using NocoDB.
# Based on requirements from README.md and using proper NocoDB column types
#
# Creates three tables:
# 1. locations - Main table with GeoData, proper field types per README.md
# 2. login - Simple authentication table with Email, Name, Admin fields
# 3. settings - Configuration table with text fields only (no QR image storage)
#
# Updated: July 2025 - Always creates a new base, does not touch existing data
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1" >&2
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1" >&2
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1" >&2
}
# Load environment variables
if [ -f ".env" ]; then
# Use set -a to automatically export variables
set -a
source .env
set +a
print_success "Environment variables loaded from .env"
else
print_error ".env file not found!"
exit 1
fi
# Validate required environment variables
if [ -z "$NOCODB_API_URL" ] || [ -z "$NOCODB_API_TOKEN" ]; then
print_error "Required environment variables NOCODB_API_URL and NOCODB_API_TOKEN not set!"
exit 1
fi
# Extract base URL from API URL and set up v2 API endpoints
BASE_URL=$(echo "$NOCODB_API_URL" | sed 's|/api/v1||')
API_BASE_V1="$NOCODB_API_URL"
API_BASE_V2="${BASE_URL}/api/v2"
print_status "Using NocoDB instance: $BASE_URL"
# Function to make API calls with proper error handling
make_api_call() {
local method=$1
local endpoint=$2
local data=$3
local description=$4
local api_version=${5:-"v2"} # Default to v2
print_status "$description"
local response
local http_code
local full_url
if [[ "$api_version" == "v1" ]]; then
full_url="$API_BASE_V1$endpoint"
else
full_url="$API_BASE_V2$endpoint"
fi
print_status "Making $method request to: $full_url"
if [ "$method" = "GET" ]; then
response=$(curl -s -w "%{http_code}" -H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
--max-time 30 \
"$full_url" 2>/dev/null)
curl_exit_code=$?
else
response=$(curl -s -w "%{http_code}" -X "$method" \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
--max-time 30 \
-d "$data" \
"$full_url" 2>/dev/null)
curl_exit_code=$?
fi
if [[ $curl_exit_code -ne 0 ]]; then
print_error "Network error occurred while making API call (curl exit code: $curl_exit_code)"
return 1
fi
if [[ -z "$response" ]]; then
print_error "Empty response from API call"
return 1
fi
http_code="${response: -3}"
response_body="${response%???}"
print_status "HTTP Code: $http_code"
print_status "Response preview: ${response_body:0:200}..."
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
print_success "$description completed successfully"
echo "$response_body"
else
print_error "$description failed with HTTP code: $http_code"
print_error "Full URL: $full_url"
print_error "Response: $response_body"
return 1
fi
}
# Function to create a project/base
create_project() {
local project_name="$1"
local project_data='{
"title": "'"$project_name"'",
"description": "Auto-generated project for NocoDB Map Viewer",
"color": "#24716E"
}'
make_api_call "POST" "/meta/bases" "$project_data" "Creating project: $project_name" "v2"
}
# Function to create a table
create_table() {
local base_id=$1
local table_name=$2
local table_data=$3
local description=$4
# Always create new table (no checking for existing)
local response
response=$(make_api_call "POST" "/meta/bases/$base_id/tables" "$table_data" "Creating table: $table_name ($description)" "v2")
if [[ $? -eq 0 && -n "$response" ]]; then
# Extract table ID from response
local table_id
table_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ -n "$table_id" ]]; then
print_success "Table '$table_name' created with ID: $table_id"
echo "$table_id"
else
print_error "Failed to extract table ID from response"
return 1
fi
else
print_error "Failed to create table: $table_name"
return 1
fi
}
# Function to test API connectivity
test_api_connectivity() {
print_status "Testing API connectivity..."
# Test basic connectivity first
if ! curl -s --max-time 10 -I "$BASE_URL" > /dev/null 2>&1; then
print_error "Cannot reach NocoDB instance at $BASE_URL"
return 1
fi
# Test API with token using v2 endpoint
local test_response
test_response=$(curl -s --max-time 10 -w "%{http_code}" -H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE_V2/meta/bases" 2>/dev/null || echo "CURL_ERROR")
if [[ "$test_response" == "CURL_ERROR" ]]; then
print_error "Network error when testing API"
return 1
fi
local http_code="${test_response: -3}"
local response_body="${test_response%???}"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
print_success "API connectivity test successful"
return 0
else
print_error "API test failed with HTTP code: $http_code"
print_error "Response: $response_body"
return 1
fi
}
# Function to create new project with timestamp
create_new_project() {
# Generate unique project name with timestamp
local timestamp=$(date +"%Y%m%d_%H%M%S")
local project_name="Map Viewer Project - $timestamp"
# First test API connectivity
if ! test_api_connectivity; then
print_error "API connectivity test failed"
exit 1
fi
print_status "Creating new base: $project_name"
print_warning "This script will create a new base and will NOT touch any existing data"
local new_base_response
new_base_response=$(create_project "$project_name")
if [[ $? -eq 0 ]]; then
local new_base_id
new_base_id=$(echo "$new_base_response" | grep -o '"id":"[^"]*"' | head -1 | sed 's/"id":"//;s/"//')
if [ -n "$new_base_id" ]; then
print_success "Created new base '$project_name' with ID: $new_base_id"
echo "$new_base_id"
return 0
else
print_error "Failed to extract base ID from response"
exit 1
fi
else
print_error "Failed to create new base"
exit 1
fi
}
# Function to create the main locations table
create_locations_table() {
local base_id=$1
local table_data='{
"table_name": "locations",
"title": "Locations",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "geo_location",
"title": "Geo-Location",
"uidt": "GeoData",
"rqd": false
},
{
"column_name": "latitude",
"title": "latitude",
"uidt": "Decimal",
"rqd": false,
"precision": 10,
"scale": 8
},
{
"column_name": "longitude",
"title": "longitude",
"uidt": "Decimal",
"rqd": false,
"precision": 11,
"scale": 8
},
{
"column_name": "first_name",
"title": "First Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "last_name",
"title": "Last Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "email",
"title": "Email",
"uidt": "Email",
"rqd": false
},
{
"column_name": "phone",
"title": "Phone",
"uidt": "PhoneNumber",
"rqd": false
},
{
"column_name": "unit_number",
"title": "Unit Number",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "support_level",
"title": "Support Level",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "1", "color": "#4CAF50"},
{"title": "2", "color": "#FFEB3B"},
{"title": "3", "color": "#FF9800"},
{"title": "4", "color": "#F44336"}
]
}
},
{
"column_name": "address",
"title": "Address",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "sign",
"title": "Sign",
"uidt": "Checkbox",
"rqd": false
},
{
"column_name": "sign_size",
"title": "Sign Size",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "Small", "color": "#2196F3"},
{"title": "Medium", "color": "#FF9800"},
{"title": "Large", "color": "#4CAF50"}
]
}
},
{
"column_name": "notes",
"title": "Notes",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "title",
"title": "title",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "category",
"title": "category",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "Important", "color": "#F44336"},
{"title": "Event", "color": "#4CAF50"},
{"title": "Business", "color": "#2196F3"},
{"title": "Other", "color": "#FF9800"}
]
}
},
{
"column_name": "created_at",
"title": "Created At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "updated_at",
"title": "Updated At",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "locations" "$table_data" "Main locations table for map data"
}
# Function to create the login table
create_login_table() {
local base_id=$1
local table_data='{
"table_name": "login",
"title": "Login",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "email",
"title": "Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "password",
"title": "Password",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "name",
"title": "Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "admin",
"title": "Admin",
"uidt": "Checkbox",
"rqd": false
},
{
"column_name": "created_at",
"title": "Created At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "last_login",
"title": "Last Login",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "login" "$table_data" "User authentication table"
}
# Function to create the settings table
create_settings_table() {
local base_id=$1
local table_data='{
"table_name": "settings",
"title": "Settings",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "created_at",
"title": "created_at",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "created_by",
"title": "created_by",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "geo_location",
"title": "Geo-Location",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "latitude",
"title": "latitude",
"uidt": "Decimal",
"rqd": false,
"precision": 10,
"scale": 8
},
{
"column_name": "longitude",
"title": "longitude",
"uidt": "Decimal",
"rqd": false,
"precision": 11,
"scale": 8
},
{
"column_name": "zoom",
"title": "zoom",
"uidt": "Number",
"rqd": false
},
{
"column_name": "walk_sheet_title",
"title": "Walk Sheet Title",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "walk_sheet_subtitle",
"title": "Walk Sheet Subtitle",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "walk_sheet_footer",
"title": "Walk Sheet Footer",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "qr_code_1_url",
"title": "QR Code 1 URL",
"uidt": "URL",
"rqd": false
},
{
"column_name": "qr_code_1_label",
"title": "QR Code 1 Label",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "qr_code_2_url",
"title": "QR Code 2 URL",
"uidt": "URL",
"rqd": false
},
{
"column_name": "qr_code_2_label",
"title": "QR Code 2 Label",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "qr_code_3_url",
"title": "QR Code 3 URL",
"uidt": "URL",
"rqd": false
},
{
"column_name": "qr_code_3_label",
"title": "QR Code 3 Label",
"uidt": "SingleLineText",
"rqd": false
}
]
}'
create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields"
}
# Function to create the shifts table
create_shifts_table() {
local base_id=$1
local table_data='{
"table_name": "shifts",
"title": "Shifts",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "title",
"title": "Title",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "description",
"title": "Description",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "date",
"title": "Date",
"uidt": "Date",
"rqd": true
},
{
"column_name": "start_time",
"title": "Start Time",
"uidt": "Time",
"rqd": true
},
{
"column_name": "end_time",
"title": "End Time",
"uidt": "Time",
"rqd": true
},
{
"column_name": "location",
"title": "Location",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "max_volunteers",
"title": "Max Volunteers",
"uidt": "Number",
"rqd": true
},
{
"column_name": "current_volunteers",
"title": "Current Volunteers",
"uidt": "Number",
"rqd": false
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "Open", "color": "#4CAF50"},
{"title": "Full", "color": "#FF9800"},
{"title": "Cancelled", "color": "#F44336"}
]
}
},
{
"column_name": "created_by",
"title": "Created By",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "created_at",
"title": "Created At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "updated_at",
"title": "Updated At",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "shifts" "$table_data" "shifts table"
}
# Function to create the shift signups table
create_shift_signups_table() {
local base_id=$1
local table_data='{
"table_name": "shift_signups",
"title": "Shift Signups",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "shift_id",
"title": "Shift ID",
"uidt": "Number",
"rqd": true
},
{
"column_name": "user_email",
"title": "User Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "user_name",
"title": "User Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "signup_date",
"title": "Signup Date",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"rqd": false,
"colOptions": {
"options": [
{"title": "Confirmed", "color": "#4CAF50"},
{"title": "Cancelled", "color": "#F44336"}
]
}
}
]
}'
create_table "$base_id" "shift_signups" "$table_data" "shift signups table"
}
# Function to create default admin user
create_default_admin() {
local base_id=$1
local login_table_id=$2
print_status "Creating default admin user..."
local admin_data='{
"email": "admin@thebunkerops.ca",
"password": "admin123",
"name": "Administrator",
"admin": true,
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
}'
make_api_call "POST" "/tables/$login_table_id/records" "$admin_data" "Creating default admin user" "v2"
print_warning "Default admin user created:"
print_warning " Email: admin@thebunkerops.ca"
print_warning " Name: Administrator"
print_warning " Admin: true"
print_warning " Note: This is a simplified login table for demonstration."
print_warning " You may need to implement proper authentication separately."
}
# Function to create default start location setting
create_default_start_location() {
local base_id=$1
local settings_table_id=$2
print_status "Creating default settings row with start location..."
local start_location_data='{
"created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'",
"created_by": "system",
"geo_location": "'"${DEFAULT_LAT:-53.5461}"';'"${DEFAULT_LNG:--113.4938}"'",
"latitude": '"${DEFAULT_LAT:-53.5461}"',
"longitude": '"${DEFAULT_LNG:--113.4938}"',
"zoom": '"${DEFAULT_ZOOM:-11}"',
"walk_sheet_title": "Campaign Walk Sheet",
"walk_sheet_subtitle": "Door-to-Door Canvassing Form",
"walk_sheet_footer": "Thank you for your participation in our campaign!",
"qr_code_1_url": "https://example.com/signup",
"qr_code_1_label": "Sign Up",
"qr_code_2_url": "https://example.com/donate",
"qr_code_2_label": "Donate",
"qr_code_3_url": "https://example.com/volunteer",
"qr_code_3_label": "Volunteer"
}'
make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default settings row" "v2"
}
# Main execution
main() {
print_status "Starting NocoDB Auto-Setup..."
print_status "================================"
# Always create a new project
print_status "Creating new base..."
print_warning "This script creates a NEW base and does NOT modify existing data"
BASE_ID=$(create_new_project)
if [ -z "$BASE_ID" ]; then
print_error "Failed to create new base"
exit 1
fi
print_status "Working with new base ID: $BASE_ID"
# Create tables
print_status "Creating tables..."
# Create locations table
LOCATIONS_TABLE_ID=$(create_locations_table "$BASE_ID")
# Create login table
LOGIN_TABLE_ID=$(create_login_table "$BASE_ID")
# Create settings table
SETTINGS_TABLE_ID=$(create_settings_table "$BASE_ID")
# Create shifts table
SHIFTS_TABLE_ID=$(create_shifts_table "$BASE_ID")
# Create shift signups table
SHIFT_SIGNUPS_TABLE_ID=$(create_shift_signups_table "$BASE_ID")
# Wait a moment for tables to be fully created
sleep 3
# Create default data
print_status "Setting up default data..."
# Create default admin user
create_default_admin "$BASE_ID" "$LOGIN_TABLE_ID"
# Create default settings row (includes both start location and walk sheet config)
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
print_status "================================"
print_success "NocoDB Auto-Setup completed successfully!"
print_status "================================"
print_status "Base ID: $BASE_ID"
print_status ""
print_status "Next steps:"
print_status "1. Login to your NocoDB instance at: $BASE_URL"
print_status "2. Find your new base and navigate to each table"
print_status "3. For each table, copy the view URL and update your .env file:"
print_status " - NOCODB_VIEW_URL (for locations table)"
print_status " - NOCODB_LOGIN_SHEET (for login table)"
print_status " - NOCODB_SETTINGS_SHEET (for settings table)"
print_status " - NOCODB_SHIFTS_SHEET (for shifts table)"
print_status " - NOCODB_SHIFT_SIGNUPS_SHEET (for shift signups table)"
print_status "4. The default admin user is: admin@thebunkerops.ca with password: admin123"
print_status "5. IMPORTANT: Change the default password after first login!"
print_status "6. Start adding your location data!"
print_warning ""
print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
print_warning "Please update your .env file with the new table URLs from the newly created base."
print_warning "SECURITY: Change the default admin password immediately after first login!"
print_warning ""
print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
print_warning "Please update your .env file with the new table URLs from the newly created base."
}
# Check if script is being run directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi

View File

@ -25,3 +25,9 @@ services:
options:
max-size: "10m"
max-file: "3"
networks:
- changemakerlite_changemaker-lite
networks:
changemakerlite_changemaker-lite:
external: true