mirror of
https://git.lindalindsay.org/admin/linda.lindsay.changemaker.git
synced 2026-04-29 03:46:42 -06:00
General map updates
This commit is contained in:
parent
e7269e808f
commit
b8439066cc
@ -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
129
map/ADMIN_IMPLEMENTATION.md
Normal 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.
|
||||
244
map/README.md
244
map/README.md
@ -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
|
||||
@ -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
122
map/app/config/index.js
Normal 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
|
||||
};
|
||||
138
map/app/controllers/authController.js
Normal file
138
map/app/controllers/authController.js
Normal 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();
|
||||
257
map/app/controllers/locationsController.js
Normal file
257
map/app/controllers/locationsController.js
Normal 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();
|
||||
370
map/app/controllers/settingsController.js
Normal file
370
map/app/controllers/settingsController.js
Normal 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();
|
||||
461
map/app/controllers/shiftsController.js
Normal file
461
map/app/controllers/shiftsController.js
Normal 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();
|
||||
146
map/app/controllers/usersController.js
Normal file
146
map/app/controllers/usersController.js
Normal 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();
|
||||
34
map/app/middleware/auth.js
Normal file
34
map/app/middleware/auth.js
Normal 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
|
||||
};
|
||||
44
map/app/middleware/rateLimiter.js
Normal file
44
map/app/middleware/rateLimiter.js
Normal 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
|
||||
};
|
||||
414
map/app/package-lock.json
generated
414
map/app/package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
310
map/app/public/admin.html
Normal 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>
|
||||
815
map/app/public/css/admin.css
Normal file
815
map/app/public/css/admin.css
Normal 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;
|
||||
}
|
||||
}
|
||||
161
map/app/public/css/shifts.css
Normal file
161
map/app/public/css/shifts.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
1
map/app/public/favicon.ico
Normal file
1
map/app/public/favicon.ico
Normal file
@ -0,0 +1 @@
|
||||
<!-- Simple favicon placeholder -->
|
||||
@ -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
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
81
map/app/public/js/auth.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
map/app/public/js/config.js
Normal file
9
map/app/public/js/config.js
Normal 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
|
||||
};
|
||||
367
map/app/public/js/location-manager.js
Normal file
367
map/app/public/js/location-manager.js
Normal 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
49
map/app/public/js/main.js
Normal 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);
|
||||
}
|
||||
});
|
||||
107
map/app/public/js/map-manager.js
Normal file
107
map/app/public/js/map-manager.js
Normal 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: '© <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
1031
map/app/public/js/map.js.backup
Normal file
1031
map/app/public/js/map.js.backup
Normal file
File diff suppressed because it is too large
Load Diff
293
map/app/public/js/shifts.js
Normal file
293
map/app/public/js/shifts.js
Normal 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;
|
||||
}
|
||||
376
map/app/public/js/ui-controls.js
Normal file
376
map/app/public/js/ui-controls.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
67
map/app/public/js/utils.js
Normal file
67
map/app/public/js/utils.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
});
|
||||
|
||||
|
||||
52
map/app/public/shifts.html
Normal file
52
map/app/public/shifts.html
Normal 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
13
map/app/routes/admin.js
Normal 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
15
map/app/routes/auth.js
Normal 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
225
map/app/routes/debug.js
Normal 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
109
map/app/routes/index.js
Normal 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');
|
||||
});
|
||||
};
|
||||
21
map/app/routes/locations.js
Normal file
21
map/app/routes/locations.js
Normal 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
48
map/app/routes/qr.js
Normal 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;
|
||||
13
map/app/routes/settings.js
Normal file
13
map/app/routes/settings.js
Normal 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
18
map/app/routes/shifts.js
Normal 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
14
map/app/routes/users.js
Normal 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
2051
map/app/server copy.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
143
map/app/services/nocodb.js
Normal 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
162
map/app/services/qrcode.js
Normal 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
184
map/app/utils/helpers.js
Normal 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
33
map/app/utils/logger.js
Normal 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
441
map/build-nocodb.md
Normal 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
856
map/build-nocodb.sh
Executable 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
|
||||
@ -25,3 +25,9 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- changemakerlite_changemaker-lite
|
||||
|
||||
networks:
|
||||
changemakerlite_changemaker-lite:
|
||||
external: true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user