From c973fe55cd5cbe6471fb2cf3c951681bfba281b8 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 15 Aug 2025 11:14:38 -0600 Subject: [PATCH] listmonk sync --- map/README.md | 246 ++++++++- map/app/controllers/listmonkController.js | 252 +++++++++ map/app/controllers/locationsController.js | 90 ++++ map/app/controllers/usersController.js | 74 +++ map/app/public/admin.html | 199 +++++++ map/app/public/css/modules/listmonk.css | 353 +++++++++++++ map/app/public/css/style.css | 1 + map/app/public/index.html | 3 + map/app/public/js/admin.js | 83 +++ map/app/public/js/listmonk-admin.js | 570 +++++++++++++++++++++ map/app/public/js/listmonk-status.js | 225 ++++++++ map/app/routes/admin.js | 41 ++ map/app/routes/index.js | 4 + map/app/routes/listmonk.js | 25 + map/app/server.js | 84 +++ map/app/services/listmonk.js | 513 +++++++++++++++++++ map/files-explainer.md | 34 +- map/instruct/LISTMONK_INTEGRATION_GUIDE.md | 318 ++++++++++++ map/listmonk-env-example.txt | 14 + map/test-listmonk-integration.sh | 293 +++++++++++ 20 files changed, 3416 insertions(+), 6 deletions(-) create mode 100644 map/app/controllers/listmonkController.js create mode 100644 map/app/public/css/modules/listmonk.css create mode 100644 map/app/public/js/listmonk-admin.js create mode 100644 map/app/public/js/listmonk-status.js create mode 100644 map/app/routes/listmonk.js create mode 100644 map/app/services/listmonk.js create mode 100644 map/instruct/LISTMONK_INTEGRATION_GUIDE.md create mode 100644 map/listmonk-env-example.txt create mode 100755 map/test-listmonk-integration.sh diff --git a/map/README.md b/map/README.md index 9e198cb..ada0aa7 100644 --- a/map/README.md +++ b/map/README.md @@ -1,6 +1,18 @@ -# NocoDB Map Viewer - -A- ๏ฟฝ **Automated shift email notifications** - Send shift details to volunteers with visual progress tracking +# NocoDB Map V- ๐Ÿ”’ 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 +- ๐Ÿ“… Volunteer shift management system with calendar and grid views +- โœ‹ User shift signup and cancellation with color-coded calendar +- ๐Ÿ“… Calendar integration (Google, Outlook, Apple) for shift export +- ๐Ÿ‘ฅ Admin shift creation and management with volunteer assignment +- ๐Ÿ“ง **Automated shift email notifications** - Send shift details to volunteers with visual progress tracking +- ๐Ÿ‘จโ€๐Ÿ’ผ User management panel for admin users (create, delete users) +- ๐Ÿ“ง **Admin broadcast emailing** - Rich HTML email composer with live preview and delivery tracking +- ๐Ÿ“ง **Listmonk Integration** - Real-time sync with self-hosted newsletter platform for advanced email marketing +- ๐Ÿ” Role-based access control (Admin vs User permissions)Automated shift email notifications** - Send shift details to volunteers with visual progress tracking - ๏ฟฝ๐Ÿ‘จโ€๐Ÿ’ผ User management panel for admin users (create, delete users) - ๏ฟฝ **Admin broadcast emailing** - Rich HTML email composer with live preview and delivery trackingntainerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js. @@ -110,6 +122,14 @@ A- ๏ฟฝ **Automated shift email notifications** - Send shift details to volunteer EMAIL_FROM_NAME=CMlite Support EMAIL_FROM_ADDRESS=noreply@cmlite.org APP_NAME=CMlite Map + + # Listmonk Integration (Optional - enhances email marketing capabilities) + LISTMONK_URL=http://localhost:9000 + LISTMONK_USERNAME=admin + LISTMONK_PASSWORD=your-listmonk-password + LISTMONK_ENABLED=true + LISTMONK_SYNC_ON_STARTUP=true + LISTMONK_AUTO_CREATE_LISTS=true ``` 3. **Auto-Create Database Structure** @@ -294,6 +314,226 @@ The system includes comprehensive email functionality powered by SMTP configurat - **Multi-format support** (HTML and plain text) - **Consistent branding** across all email communications +## Listmonk Integration + +The application includes real-time integration with [Listmonk](https://listmonk.app/), a self-hosted newsletter and mailing list manager, enabling advanced email marketing capabilities and subscriber management. + +### ๐Ÿ”„ Real-Time Synchronization + +The system automatically synchronizes data between the Map application and Listmonk: + +- **Automatic List Creation**: Creates and maintains email lists for different user segments +- **Real-Time Updates**: New locations and users are instantly synced to Listmonk +- **Bi-Directional Sync**: Unsubscribes in Listmonk update the Map database +- **Error Handling**: Visual status indicators and detailed logging for sync failures + +### ๐Ÿ“‹ Automated List Management + +The integration automatically manages the following Listmonk subscriber lists: + +- **All Locations**: Complete list of all mapped locations with contact information +- **All Users**: List of all registered Map application users +- **Support Level Lists**: Separate lists for each support level (1-4) for targeted campaigns +- **Sign Status Lists**: Lists based on campaign sign status (Has Sign, No Sign) +- **Combined Lists**: Smart combinations like "Support Level 4 with Signs" for precision targeting + +### โš™๏ธ Configuration + +Add the following environment variables to your `.env` file: + +```env +# Listmonk Integration (Optional - enhances email marketing capabilities) +LISTMONK_API_URL=http://172.20.0.9:9000/api +LISTMONK_USERNAME=admin +LISTMONK_PASSWORD=your-listmonk-password +LISTMONK_SYNC_ENABLED=true + +# Listmonk Sync Settings +LISTMONK_INITIAL_SYNC=true # Set to true only for first run to sync existing data +``` + +### ๐Ÿ” Finding the Correct Listmonk API URL + +The `LISTMONK_API_URL` must point to the internal Docker network address where Listmonk is running. Here are several methods to find the correct URL: + +#### Method 1: Using Docker Inspect (Recommended) + +If Listmonk is running in a Docker container, find its internal IP address: + +```bash +# List all running containers +docker ps + +# Find the Listmonk container name/ID, then inspect it +docker inspect | grep IPAddress + +# Example output: +# "IPAddress": "172.20.0.9" +``` + +Your `LISTMONK_API_URL` would then be: `http://172.20.0.9:9000/api` + +#### Method 2: Using Docker Network Inspection + +If both containers are on the same Docker network: + +```bash +# List Docker networks +docker network ls + +# Inspect the network (usually named after your docker-compose project) +docker network inspect + +# Look for the Listmonk container in the "Containers" section +# Note the "IPv4Address" value +``` + +#### Method 3: Using Container Names (Docker Compose) + +If using Docker Compose with both services in the same `docker-compose.yml`: + +```yaml +# In your docker-compose.yml +services: + listmonk: + image: listmonk/listmonk:latest + container_name: listmonk + # ... other config + + map-app: + # ... your map application config +``` + +You can use the service name: `LISTMONK_API_URL=http://listmonk:9000/api` + +#### Method 4: Check Docker Compose Logs + +```bash +# View Listmonk container logs to see what IP it's binding to +docker-compose logs listmonk + +# Look for lines like: +# listmonk_1 | INFO[0000] listmonk started. IP: 0.0.0.0:9000 +``` + +#### Method 5: Testing Connectivity + +Test if your URL is correct by running a test from within your Map application container: + +```bash +# Access your map application container +docker exec -it /bin/bash + +# Test connectivity to Listmonk +curl http://172.20.0.9:9000/api/health +# Should return: {"data":true} + +# Or test with your credentials +curl -u "admin:your-password" http://172.20.0.9:9000/api/lists +``` + +#### Common IP Ranges + +Docker typically assigns containers to these network ranges: +- `172.17.0.x` - Default bridge network +- `172.18.0.x` - Custom bridge networks +- `172.20.0.x` - Docker Compose networks +- `192.168.x.x` - Some custom networks + +#### Troubleshooting Connection Issues + +If you're having connection problems: + +1. **Verify Listmonk is running**: `docker ps | grep listmonk` +2. **Check port exposure**: Ensure Listmonk exposes port 9000 +3. **Network connectivity**: Both containers must be on the same Docker network +4. **Firewall rules**: Ensure no firewalls block inter-container communication +5. **DNS resolution**: Try IP address instead of container name if DNS fails + +#### Example Working Configuration + +```env +# Working example from a real deployment +LISTMONK_API_URL=http://172.20.0.9:9000/api +LISTMONK_USERNAME=API +LISTMONK_PASSWORD=s0f6qSuWTGMX8AWbz8f4EQxGFSZCZxAC +LISTMONK_SYNC_ENABLED=true +LISTMONK_INITIAL_SYNC=true +``` + +> **Note**: Never use `localhost` or `127.0.0.1` for the Listmonk URL when running in Docker containers, as this refers to the container's own loopback interface, not the Listmonk container. + +### ๐Ÿ“Š Admin Features + +Administrators have access to comprehensive Listmonk management tools: + +#### Real-Time Status Monitoring +- **Connection Status**: Live indicator showing Listmonk connectivity +- **Sync Statistics**: Real-time counts of subscribers, lists, and sync operations +- **Error Notifications**: Popup alerts and terminal logs for sync failures +- **Last Sync Timestamps**: Track when data was last synchronized + +#### Bulk Operations +- **Full Resync**: Manually trigger complete data synchronization +- **Selective Sync**: Sync specific data types (locations, users, lists) +- **Progress Tracking**: Visual progress bars for bulk operations +- **Detailed Reports**: Success/failure counts with error details + +#### List Management +- **Auto-List Creation**: Automatically create and maintain subscriber lists +- **Custom Segmentation**: Create lists based on support levels, sign status, and combinations +- **Subscriber Counts**: Real-time subscriber counts for each list +- **List Status Monitoring**: Track list health and sync status + +### ๐Ÿ”Œ API Integration Points + +The Listmonk integration adds the following API endpoints: + +#### Admin Listmonk Endpoints (requires admin privileges) +- `GET /api/listmonk/status` - Get connection status and sync statistics +- `POST /api/listmonk/sync/full` - Trigger full synchronization +- `POST /api/listmonk/sync/locations` - Sync locations only +- `POST /api/listmonk/sync/users` - Sync users only +- `POST /api/listmonk/test-connection` - Test Listmonk connectivity +- `POST /api/listmonk/reinitialize` - Recreate all lists and resync data +- `GET /api/listmonk/lists` - Get all Listmonk lists with subscriber counts + +### ๐Ÿš€ Getting Started with Listmonk + +1. **Set up Listmonk**: Deploy Listmonk using Docker Compose (see parent project documentation) +2. **Configure Integration**: Add Listmonk credentials to your `.env` file +3. **Initialize Lists**: Lists are automatically created on first startup +4. **Monitor Status**: Check the admin panel for sync status and connection health +5. **Customize Campaigns**: Use Listmonk's web interface to create targeted email campaigns + +### ๐Ÿ” Status Indicators + +The system provides visual feedback on integration status: + +- **๐ŸŸข Connected**: Listmonk is accessible and syncing properly +- **๐ŸŸก Warning**: Sync delays or minor issues detected +- **๐Ÿ”ด Error**: Connection failed or sync errors occurred +- **โšช Disabled**: Listmonk integration is disabled + +### ๐Ÿ“ˆ Benefits + +- **Enhanced Email Marketing**: Leverage Listmonk's advanced campaign features +- **Automated Segmentation**: Automatic subscriber lists based on Map data +- **Improved Deliverability**: Professional email marketing infrastructure +- **Real-Time Updates**: Always-current subscriber information +- **Detailed Analytics**: Track email engagement and campaign performance +- **Professional Templates**: Rich HTML email templates and campaign builder + +### ๐Ÿ”ง Troubleshooting + +Common issues and solutions: + +- **Connection Failed**: Verify Listmonk URL and credentials in `.env` +- **Sync Errors**: Check terminal logs for detailed error messages +- **Missing Lists**: Enable `LISTMONK_AUTO_CREATE_LISTS=true` and restart +- **Slow Sync**: Monitor network connectivity and Listmonk performance +- **Duplicate Subscribers**: The system automatically handles duplicates using email addresses + ## API Endpoints ### Public Endpoints diff --git a/map/app/controllers/listmonkController.js b/map/app/controllers/listmonkController.js new file mode 100644 index 0000000..a7ef1f4 --- /dev/null +++ b/map/app/controllers/listmonkController.js @@ -0,0 +1,252 @@ +const listmonkService = require('../services/listmonk'); +const nocodbService = require('../services/nocodb'); +const logger = require('../utils/logger'); + +// Get Listmonk sync status +exports.getSyncStatus = async (req, res) => { + try { + const status = listmonkService.getSyncStatus(); + + // Also check connection if it's enabled + if (status.enabled && !status.connected) { + // Try to reconnect + const reconnected = await listmonkService.checkConnection(); + status.connected = reconnected; + } + + res.json(status); + } catch (error) { + logger.error('Failed to get Listmonk status', error); + res.status(500).json({ + success: false, + error: 'Failed to get sync status' + }); + } +}; + +// Bulk sync all locations to Listmonk +exports.syncAllLocations = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + const locationData = await nocodbService.getLocations(); + const locations = locationData?.list || []; + + if (!locations || locations.length === 0) { + return res.json({ + success: true, + message: 'No locations to sync', + results: { total: 0, success: 0, failed: 0, errors: [] } + }); + } + + const results = await listmonkService.bulkSync(locations, 'location'); + + res.json({ + success: true, + message: `Bulk location sync completed: ${results.success} succeeded, ${results.failed} failed`, + results + }); + } catch (error) { + logger.error('Bulk location sync failed', error); + res.status(500).json({ + success: false, + error: 'Failed to sync locations to Listmonk' + }); + } +}; + +// Bulk sync all users to Listmonk +exports.syncAllUsers = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + const config = require('../config'); + const userData = await nocodbService.getAllPaginated(config.nocodb.loginSheetId); + const users = userData?.list || []; + + if (!users || users.length === 0) { + return res.json({ + success: true, + message: 'No users to sync', + results: { total: 0, success: 0, failed: 0, errors: [] } + }); + } + + const results = await listmonkService.bulkSync(users, 'user'); + + res.json({ + success: true, + message: `Bulk user sync completed: ${results.success} succeeded, ${results.failed} failed`, + results + }); + } catch (error) { + logger.error('Bulk user sync failed', error); + res.status(500).json({ + success: false, + error: 'Failed to sync users to Listmonk' + }); + } +}; + +// Sync both locations and users +exports.syncAll = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + let results = { + locations: { total: 0, success: 0, failed: 0, errors: [] }, + users: { total: 0, success: 0, failed: 0, errors: [] } + }; + + // Sync locations + try { + const locationData = await nocodbService.getLocations(); + const locations = locationData?.list || []; + if (locations && locations.length > 0) { + results.locations = await listmonkService.bulkSync(locations, 'location'); + } + } catch (error) { + logger.error('Failed to sync locations during full sync', error); + results.locations.errors.push({ error: error.message }); + } + + // Sync users + try { + const userData = await nocodbService.getAllPaginated(config.nocodb.loginSheetId); + const users = userData?.list || []; + if (users && users.length > 0) { + results.users = await listmonkService.bulkSync(users, 'user'); + } + } catch (error) { + logger.error('Failed to sync users during full sync', error); + results.users.errors.push({ error: error.message }); + } + + const totalSuccess = results.locations.success + results.users.success; + const totalFailed = results.locations.failed + results.users.failed; + + res.json({ + success: true, + message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`, + results + }); + } catch (error) { + logger.error('Complete sync failed', error); + res.status(500).json({ + success: false, + error: 'Failed to perform complete sync' + }); + } +}; + +// Get Listmonk list statistics +exports.getListStats = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.json({ + success: false, + error: 'Listmonk sync is disabled', + stats: null + }); + } + + const stats = await listmonkService.getListStats(); + + // Convert stats object to array format for frontend + let statsArray = []; + if (stats && typeof stats === 'object') { + statsArray = Object.entries(stats).map(([key, list]) => ({ + id: key, + name: list.name, + subscriberCount: list.subscriber_count || 0, + description: `Email list for ${key}` + })); + } + + res.json({ + success: true, + stats: statsArray + }); + } catch (error) { + logger.error('Failed to get Listmonk list stats', error); + res.status(500).json({ + success: false, + error: 'Failed to get list statistics' + }); + } +}; + +// Test Listmonk connection +exports.testConnection = async (req, res) => { + try { + const connected = await listmonkService.checkConnection(); + + if (connected) { + res.json({ + success: true, + message: 'Listmonk connection successful', + connected: true + }); + } else { + res.json({ + success: false, + message: listmonkService.lastError || 'Connection failed', + connected: false + }); + } + } catch (error) { + logger.error('Failed to test Listmonk connection', error); + res.status(500).json({ + success: false, + error: 'Failed to test connection' + }); + } +}; + +// Reinitialize Listmonk lists +exports.reinitializeLists = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + const initialized = await listmonkService.initializeLists(); + + if (initialized) { + res.json({ + success: true, + message: 'Listmonk lists reinitialized successfully' + }); + } else { + res.json({ + success: false, + message: listmonkService.lastError || 'Failed to initialize lists' + }); + } + } catch (error) { + logger.error('Failed to reinitialize Listmonk lists', error); + res.status(500).json({ + success: false, + error: 'Failed to reinitialize lists' + }); + } +}; diff --git a/map/app/controllers/locationsController.js b/map/app/controllers/locationsController.js index 96b1143..7566708 100644 --- a/map/app/controllers/locationsController.js +++ b/map/app/controllers/locationsController.js @@ -1,6 +1,7 @@ const nocodbService = require('../services/nocodb'); const logger = require('../utils/logger'); const config = require('../config'); +const listmonkService = require('../services/listmonk'); const { syncGeoFields, validateCoordinates, @@ -196,6 +197,32 @@ class LocationsController { logger.info('Location created successfully:', extractId(response)); + // Real-time sync to Listmonk (async, don't block response) + if (listmonkService.syncEnabled && response.Email) { + setImmediate(async () => { + try { + const syncResult = await listmonkService.syncLocation(response); + if (!syncResult.success) { + logger.warn('Listmonk sync failed for new location', { + locationId: extractId(response), + email: response.Email, + error: syncResult.error + }); + } else { + logger.debug('Location synced to Listmonk', { + locationId: extractId(response), + email: response.Email + }); + } + } catch (error) { + logger.error('Listmonk sync error for new location', { + locationId: extractId(response), + error: error.message + }); + } + }); + } + res.status(201).json({ success: true, location: response @@ -257,6 +284,32 @@ class LocationsController { logger.info('Location updated successfully:', locationId); + // Real-time sync to Listmonk (async, don't block response) + if (listmonkService.syncEnabled && response.Email) { + setImmediate(async () => { + try { + const syncResult = await listmonkService.syncLocation(response); + if (!syncResult.success) { + logger.warn('Listmonk sync failed for updated location', { + locationId: locationId, + email: response.Email, + error: syncResult.error + }); + } else { + logger.debug('Updated location synced to Listmonk', { + locationId: locationId, + email: response.Email + }); + } + } catch (error) { + logger.error('Listmonk sync error for updated location', { + locationId: locationId, + error: error.message + }); + } + }); + } + res.json({ success: true, location: response @@ -285,6 +338,17 @@ class LocationsController { }); } + // Get location data before deletion (for Listmonk cleanup) + let locationData = null; + if (listmonkService.syncEnabled) { + try { + const getResponse = await nocodbService.getById(config.nocodb.tableId, locationId); + locationData = getResponse; + } catch (error) { + logger.warn('Could not fetch location data before deletion', error.message); + } + } + await nocodbService.delete( config.nocodb.tableId, locationId @@ -292,6 +356,32 @@ class LocationsController { logger.info(`Location ${locationId} deleted by ${req.session.userEmail}`); + // Remove from Listmonk (async, don't block response) + if (listmonkService.syncEnabled && locationData && locationData.Email) { + setImmediate(async () => { + try { + const syncResult = await listmonkService.removeSubscriber(locationData.Email); + if (!syncResult.success) { + logger.warn('Failed to remove deleted location from Listmonk', { + locationId: locationId, + email: locationData.Email, + error: syncResult.error + }); + } else { + logger.debug('Deleted location removed from Listmonk', { + locationId: locationId, + email: locationData.Email + }); + } + } catch (error) { + logger.error('Listmonk cleanup error for deleted location', { + locationId: locationId, + error: error.message + }); + } + }); + } + res.json({ success: true, message: 'Location deleted successfully' diff --git a/map/app/controllers/usersController.js b/map/app/controllers/usersController.js index ccae6bd..3fefae9 100644 --- a/map/app/controllers/usersController.js +++ b/map/app/controllers/usersController.js @@ -3,6 +3,7 @@ const logger = require('../utils/logger'); const config = require('../config'); const { sanitizeUser, extractId } = require('../utils/helpers'); const { sendLoginDetails } = require('../services/email'); +const listmonkService = require('../services/listmonk'); class UsersController { async getAll(req, res) { @@ -111,6 +112,42 @@ class UsersController { userData ); + // Real-time sync to Listmonk (async, don't block response) + if (listmonkService.syncEnabled && email) { + setImmediate(async () => { + try { + const userForSync = { + ID: extractId(response), + Email: email, + Name: name, + Admin: isAdmin, + UserType: userType, + 'Created At': new Date().toISOString(), + ExpiresAt: expiresAt + }; + + const syncResult = await listmonkService.syncUser(userForSync); + if (!syncResult.success) { + logger.warn('Listmonk sync failed for new user', { + userId: extractId(response), + email: email, + error: syncResult.error + }); + } else { + logger.debug('User synced to Listmonk', { + userId: extractId(response), + email: email + }); + } + } catch (error) { + logger.error('Listmonk sync error for new user', { + email: email, + error: error.message + }); + } + }); + } + res.status(201).json({ success: true, message: 'User created successfully', @@ -152,11 +189,48 @@ class UsersController { }); } + // Get user data before deletion (for Listmonk cleanup) + let userData = null; + if (listmonkService.syncEnabled) { + try { + const getResponse = await nocodbService.getById(config.nocodb.loginSheetId, userId); + userData = getResponse; + } catch (error) { + logger.warn('Could not fetch user data before deletion', error.message); + } + } + await nocodbService.delete( config.nocodb.loginSheetId, userId ); + // Remove from Listmonk (async, don't block response) + if (listmonkService.syncEnabled && userData && userData.Email) { + setImmediate(async () => { + try { + const syncResult = await listmonkService.syncUser(userData, 'delete'); + if (!syncResult.success) { + logger.warn('Failed to remove deleted user from Listmonk', { + userId: userId, + email: userData.Email, + error: syncResult.error + }); + } else { + logger.debug('Deleted user removed from Listmonk', { + userId: userId, + email: userData.Email + }); + } + } catch (error) { + logger.error('Listmonk cleanup error for deleted user', { + userId: userId, + error: error.message + }); + } + }); + } + res.json({ success: true, message: 'User deleted successfully' diff --git a/map/app/public/admin.html b/map/app/public/admin.html index 7e56c2f..3914d56 100644 --- a/map/app/public/admin.html +++ b/map/app/public/admin.html @@ -51,6 +51,10 @@ ๐Ÿ—„๏ธ NocoDB Links + + ๐Ÿ“ง + Listmonk Links + ๐Ÿ“ Start Location @@ -75,6 +79,10 @@ ๐Ÿ“Š Convert Data + + ๐Ÿ“ง + Email Lists + + + +

Map Start Location

@@ -976,6 +1064,113 @@
+ + + @@ -1053,6 +1248,10 @@ + + + + diff --git a/map/app/public/css/modules/listmonk.css b/map/app/public/css/modules/listmonk.css new file mode 100644 index 0000000..b55e62a --- /dev/null +++ b/map/app/public/css/modules/listmonk.css @@ -0,0 +1,353 @@ +/* Listmonk Status Indicator */ +.listmonk-status-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + transition: all 0.3s ease; + cursor: help; + margin-left: 8px; + white-space: nowrap; +} + +.listmonk-status-indicator.connected { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.listmonk-status-indicator.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + animation: pulse-error 2s infinite; +} + +.listmonk-status-indicator.disabled { + background-color: #e2e3e5; + color: #6c757d; + border: 1px solid #d6d8db; +} + +.listmonk-status-indicator.checking { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; +} + +@keyframes pulse-error { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.status-icon { + font-size: 14px; + line-height: 1; +} + +.status-text { + font-size: 12px; + font-weight: 600; +} + +/* Sync Notifications */ +.sync-error-notification, +.sync-success-notification { + position: fixed; + top: 80px; + right: 20px; + max-width: 420px; + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); + z-index: 10000; + animation: slideInRight 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.sync-error-notification { + border: 2px solid #dc3545; +} + +.sync-success-notification { + border: 2px solid #28a745; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-radius: 6px 6px 0 0; + font-size: 14px; +} + +.sync-error-notification .notification-header { + background: #dc3545; + color: white; +} + +.sync-success-notification .notification-header { + background: #28a745; + color: white; +} + +.notification-body { + padding: 16px; +} + +.notification-body p { + margin: 8px 0; + color: #333; + font-size: 14px; + line-height: 1.4; +} + +.notification-body p:first-child { + margin-top: 0; +} + +.notification-body p:last-child { + margin-bottom: 0; +} + +.notification-body code { + display: block; + padding: 10px 12px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + color: #dc3545; + font-size: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + margin: 10px 0; + word-break: break-all; + line-height: 1.3; +} + +.close-btn { + background: none; + border: none; + color: white; + font-size: 18px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + line-height: 1; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.close-btn:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +/* Admin Panel Listmonk Section */ +.listmonk-section { + margin-top: 30px; +} + +.listmonk-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 20px; +} + +.listmonk-stats, +.listmonk-actions { + background: white; + padding: 20px; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.listmonk-stats h3, +.listmonk-actions h3 { + margin-top: 0; + margin-bottom: 15px; + color: #333; + font-size: 16px; + font-weight: 600; +} + +.status-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #f0f0f0; +} + +.status-row:last-child { + border-bottom: none; +} + +.status-row span:first-child { + font-weight: 500; + color: #666; +} + +.status-row span:last-child { + font-weight: 600; +} + +.status-connected { + color: #28a745; +} + +.status-error { + color: #dc3545; +} + +.status-disabled { + color: #6c757d; +} + +.sync-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sync-buttons .btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + font-size: 14px; + font-weight: 500; + text-align: left; +} + +.sync-buttons .btn span:first-child { + font-size: 16px; +} + +.sync-progress { + background: white; + padding: 20px; + border-radius: 8px; + border: 1px solid #dee2e6; + margin-top: 20px; +} + +.progress-bar-container { + margin: 15px 0; +} + +.progress-bar { + width: 100%; + height: 20px; + background-color: #f0f0f0; + border-radius: 10px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #28a745, #20c997); + border-radius: 10px; + transition: width 0.3s ease; + width: 0%; +} + +#sync-results { + margin-top: 15px; +} + +.sync-result { + padding: 10px 15px; + border-radius: 6px; + margin-bottom: 10px; + font-size: 14px; +} + +.sync-result.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.sync-result.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.sync-result.info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .listmonk-status-indicator { + font-size: 12px; + padding: 4px 8px; + gap: 4px; + } + + .status-text { + display: none; /* Hide text on mobile */ + } + + .sync-error-notification, + .sync-success-notification { + left: 10px; + right: 10px; + max-width: none; + top: 60px; + } + + .listmonk-container { + grid-template-columns: 1fr; + gap: 15px; + } + + .sync-buttons { + gap: 8px; + } + + .sync-buttons .btn { + padding: 12px 16px; + } +} + +@media (max-width: 480px) { + .listmonk-status-indicator { + padding: 3px 6px; + } + + .status-icon { + font-size: 12px; + } + + .notification-body { + padding: 12px; + } + + .notification-body code { + font-size: 11px; + padding: 8px 10px; + } +} diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index 1d6f277..db1a106 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -18,3 +18,4 @@ @import url("modules/apartment-marker.css"); @import url("modules/temp-user.css"); @import url("modules/cuts.css"); +@import url("modules/listmonk.css"); diff --git a/map/app/public/index.html b/map/app/public/index.html index f54b212..323a63f 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -444,6 +444,9 @@ + + + diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js index 6b38cfc..2b02e9d 100644 --- a/map/app/public/js/admin.js +++ b/map/app/public/js/admin.js @@ -56,6 +56,7 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { loadWalkSheetConfig(); initializeNocodbLinks(); + initializeListmonkLinks(); }, 100); // Check if URL has a hash to show specific section @@ -1998,6 +1999,88 @@ function setAdminNocodbLink(elementId, url) { } } +// Initialize Listmonk links in admin panel +async function initializeListmonkLinks() { + console.log('Starting Listmonk links initialization...'); + + try { + // Since we're in the admin panel, the user is already verified as admin + // by the requireAdmin middleware. Let's get the URLs from the server directly. + console.log('Fetching Listmonk URLs for admin panel...'); + const configResponse = await fetch('/api/admin/listmonk-urls'); + + if (!configResponse.ok) { + throw new Error(`Listmonk URLs fetch failed: ${configResponse.status} ${configResponse.statusText}`); + } + + const config = await configResponse.json(); + console.log('Listmonk URLs received:', config); + + if (config.success && config.listmonkUrls) { + console.log('Setting up Listmonk links with URLs:', config.listmonkUrls); + + // Set up admin dashboard Listmonk links + setAdminListmonkLink('admin-listmonk-admin-link', config.listmonkUrls.adminUrl); + setAdminListmonkLink('admin-listmonk-lists-link', config.listmonkUrls.listsUrl); + setAdminListmonkLink('admin-listmonk-campaigns-link', config.listmonkUrls.campaignsUrl); + setAdminListmonkLink('admin-listmonk-subscribers-link', config.listmonkUrls.subscribersUrl); + setAdminListmonkLink('admin-listmonk-settings-link', config.listmonkUrls.settingsUrl); + + console.log('Listmonk links initialized in admin panel'); + } else { + console.warn('No Listmonk URLs found in admin config response'); + // Hide the Listmonk section if no URLs are available + const listmonkSection = document.getElementById('listmonk-links'); + const listmonkNav = document.querySelector('.admin-nav a[href="#listmonk-links"]'); + if (listmonkSection) { + listmonkSection.style.display = 'none'; + console.log('Hidden Listmonk section'); + } + if (listmonkNav) { + listmonkNav.style.display = 'none'; + console.log('Hidden Listmonk nav link'); + } + } + } catch (error) { + console.error('Error initializing Listmonk links in admin panel:', error); + // Hide the Listmonk section on error + const listmonkSection = document.getElementById('listmonk-links'); + const listmonkNav = document.querySelector('.admin-nav a[href="#listmonk-links"]'); + if (listmonkSection) { + listmonkSection.style.display = 'none'; + console.log('Hidden Listmonk section due to error'); + } + if (listmonkNav) { + listmonkNav.style.display = 'none'; + console.log('Hidden Listmonk nav link due to error'); + } + } +} + +// Helper function to set admin Listmonk link href +function setAdminListmonkLink(elementId, url) { + console.log(`Setting up Listmonk link: ${elementId} = ${url}`); + const element = document.getElementById(elementId); + + if (element && url) { + element.href = url; + element.style.display = 'inline-flex'; + // Remove any disabled state + element.classList.remove('btn-disabled'); + element.removeAttribute('disabled'); + console.log(`โœ“ Successfully set up ${elementId}`); + } else if (element) { + element.style.display = 'none'; + // Add disabled state if no URL + element.classList.add('btn-disabled'); + element.setAttribute('disabled', 'disabled'); + element.href = '#'; + console.log(`โš  Disabled ${elementId} - no URL provided`); + } else { + console.error(`โœ— Element not found: ${elementId}`); + } +} + // Shift User Management Functions let currentShiftData = null; let allUsers = []; diff --git a/map/app/public/js/listmonk-admin.js b/map/app/public/js/listmonk-admin.js new file mode 100644 index 0000000..c921f02 --- /dev/null +++ b/map/app/public/js/listmonk-admin.js @@ -0,0 +1,570 @@ +/** + * Admin Listmonk Management Functions + * Handles admin interface for email list synchronization + */ + +// Global variables for admin Listmonk functionality +let syncInProgress = false; +let syncProgressInterval = null; + +/** + * Initialize Listmonk admin section + */ +async function initListmonkAdmin() { + await refreshListmonkStatus(); + await loadListmonkStats(); +} + +/** + * Refresh the Listmonk sync status display + */ +async function refreshListmonkStatus() { + console.log('๐Ÿ”„ Refreshing Listmonk status...'); + try { + const response = await fetch('/api/listmonk/status', { + credentials: 'include' + }); + + console.log('๐Ÿ“ก Status response:', response.status, response.statusText); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const status = await response.json(); + console.log('๐Ÿ“Š Status data:', status); + + updateStatusDisplay(status); + + // Update global status if available + if (window.listmonkStatus) { + window.listmonkStatus.currentStatus = status; + } + + } catch (error) { + console.error('Failed to refresh Listmonk status:', error); + updateStatusDisplay({ + enabled: false, + connected: false, + lastError: `Status check failed: ${error.message}` + }); + } +} + +/** + * Update the status display in the admin panel + */ +function updateStatusDisplay(status) { + console.log('๐ŸŽจ Updating status display with:', status); + + const connectionStatus = document.getElementById('connection-status'); + const autosyncStatus = document.getElementById('autosync-status'); + const lastError = document.getElementById('last-error'); + + console.log('๐Ÿ” Status elements found:', { + connectionStatus: !!connectionStatus, + autosyncStatus: !!autosyncStatus, + lastError: !!lastError + }); + + if (connectionStatus) { + if (status.enabled && status.connected) { + connectionStatus.innerHTML = 'โœ… Connected'; + } else if (status.enabled && !status.connected) { + connectionStatus.innerHTML = 'โŒ Failed'; + } else { + connectionStatus.innerHTML = 'โญ• Disabled'; + } + console.log('โœ… Connection status updated:', connectionStatus.innerHTML); + } + + if (autosyncStatus) { + if (status.enabled) { + autosyncStatus.innerHTML = 'โœ… Enabled'; + } else { + autosyncStatus.innerHTML = 'โญ• Disabled'; + } + console.log('โœ… Auto-sync status updated:', autosyncStatus.innerHTML); + } + + if (lastError) { + if (status.lastError) { + lastError.innerHTML = `${escapeHtml(status.lastError)}`; + } else { + lastError.innerHTML = 'None'; + } + console.log('โœ… Last error updated:', lastError.innerHTML); + } +} + +/** + * Load and display Listmonk list statistics + */ +async function loadListmonkStats() { + try { + const response = await fetch('/api/listmonk/stats', { + credentials: 'include' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const data = await response.json(); + console.log('๐Ÿ“Š Stats API response:', data); // Debug log + + if (data.success && data.stats) { + // Ensure stats is an array + const statsArray = Array.isArray(data.stats) ? data.stats : []; + console.log('๐Ÿ“Š Stats array:', statsArray); // Debug log + displayListStats(statsArray); + } else { + console.warn('No Listmonk stats available:', data.error || 'Unknown error'); + displayListStats([]); + } + + } catch (error) { + console.error('Failed to load Listmonk stats:', error); + } +} + +/** + * Display list statistics in the admin panel + */ +function displayListStats(stats) { + const statusContent = document.getElementById('sync-status-display'); + if (!statusContent) return; + + console.log('๐Ÿ“Š displayListStats called with:', stats, 'Type:', typeof stats); // Debug log + + // Ensure stats is an array + const statsArray = Array.isArray(stats) ? stats : []; + console.log('๐Ÿ“Š Stats array after conversion:', statsArray, 'Length:', statsArray.length); // Debug log + + // Add stats section if it doesn't exist + let statsSection = document.getElementById('listmonk-stats-section'); + if (!statsSection) { + statsSection = document.createElement('div'); + statsSection.id = 'listmonk-stats-section'; + statsSection.innerHTML = '

Email Lists

'; + statusContent.appendChild(statsSection); + } + + // Clear existing stats + const existingStats = statsSection.querySelector('.stats-list'); + if (existingStats) { + existingStats.remove(); + } + + // Create stats display + const statsList = document.createElement('div'); + statsList.className = 'stats-list'; + + if (statsArray.length === 0) { + statsList.innerHTML = '

No email lists found

'; + } else { + statsArray.forEach(list => { + const statRow = document.createElement('div'); + statRow.className = 'status-row'; + statRow.innerHTML = ` + ${escapeHtml(list.name)} + ${list.subscriberCount} subscribers + `; + statsList.appendChild(statRow); + }); + } + + statsSection.appendChild(statsList); +} + +/** + * Sync data to Listmonk + * @param {string} type - 'locations', 'users', or 'all' + */ +async function syncToListmonk(type) { + if (syncInProgress) { + showNotification('Sync already in progress', 'warning'); + return; + } + + syncInProgress = true; + + const progressSection = document.getElementById('sync-progress'); + const resultsDiv = document.getElementById('sync-results'); + const progressBar = document.getElementById('sync-progress-bar'); + + // Show progress section + if (progressSection) { + progressSection.style.display = 'block'; + } + + // Reset progress + if (progressBar) { + progressBar.style.width = '0%'; + } + + if (resultsDiv) { + resultsDiv.innerHTML = '
Starting sync...
'; + } + + // Disable sync buttons + const buttons = document.querySelectorAll('.sync-buttons .btn'); + buttons.forEach(btn => { + btn.disabled = true; + btn.style.opacity = '0.6'; + }); + + try { + const response = await fetch(`/api/listmonk/sync/${type}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + + // Animate progress to 100% + if (progressBar) { + progressBar.style.width = '100%'; + } + + // Display results + displaySyncResults(result, resultsDiv); + + // Refresh stats after successful sync + setTimeout(() => { + loadListmonkStats(); + }, 1000); + + } catch (error) { + console.error('Sync failed:', error); + + if (resultsDiv) { + resultsDiv.innerHTML = ` +
+ Sync Failed: ${escapeHtml(error.message)} +
+ `; + } + + showNotification('Sync failed: ' + error.message, 'error'); + } finally { + syncInProgress = false; + + // Re-enable sync buttons + buttons.forEach(btn => { + btn.disabled = false; + btn.style.opacity = '1'; + }); + } +} + +/** + * Display sync results in the admin panel + */ +function displaySyncResults(result, resultsDiv) { + if (!resultsDiv) return; + + let html = ''; + + if (result.success) { + html += `
+ โœ… ${escapeHtml(result.message)} +
`; + + // Show detailed results for different sync types + if (result.results) { + if (result.results.locations) { + html += formatSyncResults('Locations', result.results.locations); + } + if (result.results.users) { + html += formatSyncResults('Users', result.results.users); + } + + // For single type syncs + if (result.results.total !== undefined) { + html += formatSyncResults('Items', result.results); + } + } + + showNotification('Sync completed successfully!', 'success'); + } else { + html += `
+ โŒ Sync Failed: ${escapeHtml(result.error)} +
`; + showNotification('Sync failed', 'error'); + } + + resultsDiv.innerHTML = html; +} + +/** + * Format sync results for display + */ +function formatSyncResults(type, results) { + let html = `
+ ${type}: + ${results.success} succeeded, ${results.failed} failed + (${results.total} total) +
`; + + // Show errors if any + if (results.errors && results.errors.length > 0) { + html += `
+ Errors: +
    `; + + // Show only first 5 errors to avoid overwhelming the UI + const errorsToShow = results.errors.slice(0, 5); + errorsToShow.forEach(error => { + html += `
  • ${escapeHtml(error.email || error.id)}: ${escapeHtml(error.error)}
  • `; + }); + + if (results.errors.length > 5) { + html += `
  • ... and ${results.errors.length - 5} more errors
  • `; + } + + html += `
`; + } + + return html; +} + +/** + * Test Listmonk connection + */ +async function testListmonkConnection() { + try { + const response = await fetch('/api/listmonk/test-connection', { + credentials: 'include' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + if (result.success && result.connected) { + showNotification('โœ… Listmonk connection successful!', 'success'); + } else { + showNotification('โŒ Connection failed: ' + (result.message || 'Unknown error'), 'error'); + } + + // Refresh status after test + setTimeout(refreshListmonkStatus, 1000); + + } catch (error) { + console.error('Connection test failed:', error); + showNotification('โŒ Connection test failed: ' + error.message, 'error'); + } +} + +/** + * Reinitialize Listmonk lists + */ +async function reinitializeListmonk() { + if (!confirm('This will recreate all email lists. Are you sure?')) { + return; + } + + try { + const response = await fetch('/api/listmonk/reinitialize', { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + if (result.success) { + showNotification('โœ… Lists reinitialized successfully!', 'success'); + setTimeout(() => { + refreshListmonkStatus(); + loadListmonkStats(); + }, 1000); + } else { + showNotification('โŒ Failed to reinitialize: ' + result.message, 'error'); + } + + } catch (error) { + console.error('Reinitialize failed:', error); + showNotification('โŒ Reinitialize failed: ' + error.message, 'error'); + } +} + +/** + * Utility function to escape HTML + */ +function escapeHtml(text) { + if (typeof text !== 'string') return text; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Fallback notification function if showNotification is not available + */ +function showNotification(message, type = 'info') { + // Check if a global showNotification function exists + if (typeof window.showNotification === 'function' && window.showNotification !== showNotification) { + window.showNotification(message, type); + } else { + // Simple fallback - log to console and show alert for errors + console.log(`[${type.toUpperCase()}] ${message}`); + if (type === 'error') { + console.error(message); + alert(`ERROR: ${message}`); + } else if (type === 'warning') { + console.warn(message); + alert(`WARNING: ${message}`); + } else if (type === 'success') { + console.info(`SUCCESS: ${message}`); + } + } +} + +// Auto-initialize when admin page loads +function initializeListmonkEventListeners() { + console.log('๐Ÿ”ง Initializing Listmonk event listeners...'); + + // Sync Locations button + const syncLocationsBtn = document.getElementById('sync-locations-btn'); + if (syncLocationsBtn) { + syncLocationsBtn.addEventListener('click', () => { + console.log('๐Ÿ“ Sync Locations clicked'); + syncToListmonk('locations'); + }); + console.log('โœ… Sync Locations button listener added'); + } else { + console.warn('โŒ Sync Locations button not found'); + } + + // Sync Users button + const syncUsersBtn = document.getElementById('sync-users-btn'); + if (syncUsersBtn) { + syncUsersBtn.addEventListener('click', () => { + console.log('๐Ÿ‘ค Sync Users clicked'); + syncToListmonk('users'); + }); + console.log('โœ… Sync Users button listener added'); + } else { + console.warn('โŒ Sync Users button not found'); + } + + // Sync All button + const syncAllBtn = document.getElementById('sync-all-btn'); + if (syncAllBtn) { + syncAllBtn.addEventListener('click', () => { + console.log('๐Ÿ”„ Sync All clicked'); + syncToListmonk('all'); + }); + console.log('โœ… Sync All button listener added'); + } else { + console.warn('โŒ Sync All button not found'); + } + + // Refresh Status button + const refreshStatusBtn = document.getElementById('refresh-status-btn'); + if (refreshStatusBtn) { + refreshStatusBtn.addEventListener('click', () => { + console.log('๐Ÿ” Refresh Status clicked'); + refreshListmonkStatus(); + }); + console.log('โœ… Refresh Status button listener added'); + } else { + console.warn('โŒ Refresh Status button not found'); + } + + // Test Connection button + const testConnectionBtn = document.getElementById('test-connection-btn'); + if (testConnectionBtn) { + testConnectionBtn.addEventListener('click', () => { + console.log('๐Ÿ”— Test Connection clicked'); + testListmonkConnection(); + }); + console.log('โœ… Test Connection button listener added'); + } else { + console.warn('โŒ Test Connection button not found'); + } + + // Reinitialize Lists button + const reinitializeListsBtn = document.getElementById('reinitialize-lists-btn'); + if (reinitializeListsBtn) { + reinitializeListsBtn.addEventListener('click', () => { + console.log('๐Ÿ› ๏ธ Reinitialize Lists clicked'); + reinitializeListmonk(); + }); + console.log('โœ… Reinitialize Lists button listener added'); + } else { + console.warn('โŒ Reinitialize Lists button not found'); + } +} + +function attemptInitialization(attempt = 1) { + console.log(`๐Ÿ”„ Attempting Listmonk initialization (attempt ${attempt})`); + + // Try to initialize admin and event listeners directly + try { + initListmonkAdmin(); + initializeListmonkEventListeners(); + console.log('โœ… Listmonk initialization completed successfully'); + return true; + } catch (error) { + console.warn(`โš ๏ธ Attempt ${attempt} failed:`, error.message); + if (attempt < 5) { + console.log(`โฑ๏ธ Retrying in ${attempt * 500}ms...`); + setTimeout(() => attemptInitialization(attempt + 1), attempt * 500); + return false; + } else { + console.error('โŒ All initialization attempts failed'); + // Still try to initialize event listeners as a fallback + try { + initializeListmonkEventListeners(); + console.log('โœ… Event listeners initialized as fallback'); + } catch (fallbackError) { + console.error('โŒ Even fallback initialization failed:', fallbackError); + } + return false; + } + } +} + +// Also try a more direct approach - just initialize when the page is ready +function directInitialization() { + console.log('๐Ÿš€ Direct Listmonk initialization...'); + try { + initListmonkAdmin(); + initializeListmonkEventListeners(); + console.log('โœ… Direct initialization successful'); + } catch (error) { + console.error('Direct initialization failed:', error); + // Try just the event listeners + try { + initializeListmonkEventListeners(); + console.log('โœ… Event listeners initialized directly'); + } catch (listenerError) { + console.error('โŒ Even direct event listener initialization failed:', listenerError); + } + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + attemptInitialization(); + // Also try direct initialization after a delay + setTimeout(directInitialization, 2000); + }); +} else { + attemptInitialization(); + // Also try direct initialization after a delay + setTimeout(directInitialization, 1000); +} + +// Export functions for global use +window.syncToListmonk = syncToListmonk; +window.refreshListmonkStatus = refreshListmonkStatus; +window.testListmonkConnection = testListmonkConnection; +window.reinitializeListmonk = reinitializeListmonk; diff --git a/map/app/public/js/listmonk-status.js b/map/app/public/js/listmonk-status.js new file mode 100644 index 0000000..9d35028 --- /dev/null +++ b/map/app/public/js/listmonk-status.js @@ -0,0 +1,225 @@ +/** + * Listmonk Status Manager + * Handles real-time status monitoring and user notifications for email list sync + */ + +class ListmonkStatus { + constructor() { + this.statusElement = null; + this.checkInterval = null; + this.lastErrorShown = null; + this.currentStatus = { + enabled: false, + connected: false, + lastError: null + }; + this.init(); + } + + init() { + // Only initialize if user is authenticated + if (window.currentUser) { + this.createStatusIndicator(); + this.startStatusCheck(); + } + } + + createStatusIndicator() { + // Create status indicator element + const indicator = document.createElement('div'); + indicator.id = 'listmonk-status'; + indicator.className = 'listmonk-status-indicator checking'; + indicator.innerHTML = ` + โณ + Email Sync + `; + + // Find a good place to put it - try header actions first + let container = document.querySelector('.header-actions'); + if (!container) { + // Fallback to header nav + container = document.querySelector('.header-nav'); + } + if (!container) { + // Fallback to header itself + container = document.querySelector('header'); + } + + if (container) { + container.appendChild(indicator); + this.statusElement = indicator; + } + } + + async checkStatus() { + try { + const response = await fetch('/api/listmonk/status', { + credentials: 'include' + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const status = await response.json(); + this.updateIndicator(status); + + } catch (error) { + console.error('Failed to check Listmonk status:', error); + this.updateIndicator({ + enabled: false, + connected: false, + lastError: error.message + }); + } + } + + updateIndicator(status) { + if (!this.statusElement) return; + + const icon = this.statusElement.querySelector('.status-icon'); + const text = this.statusElement.querySelector('.status-text'); + + // Update current status + const wasConnected = this.currentStatus.connected; + this.currentStatus = status; + + if (!status.enabled) { + this.statusElement.className = 'listmonk-status-indicator disabled'; + icon.innerHTML = 'โญ•'; + text.textContent = 'Sync Off'; + this.statusElement.title = 'Email list synchronization is disabled'; + } else if (status.connected) { + this.statusElement.className = 'listmonk-status-indicator connected'; + icon.innerHTML = 'โœ…'; + text.textContent = 'Sync On'; + this.statusElement.title = 'Email lists are synchronizing automatically'; + + // If we just reconnected, show a brief success message + if (!wasConnected && this.lastErrorShown) { + this.showReconnectedNotification(); + } + } else { + this.statusElement.className = 'listmonk-status-indicator error'; + icon.innerHTML = 'โŒ'; + text.textContent = 'Sync Error'; + this.statusElement.title = status.lastError || 'Email list sync failed - check configuration'; + + // Show popup warning if this is a new error or first time seeing this error + const errorKey = status.lastError + (status.lastErrorTime || ''); + if ((!this.lastErrorShown || this.lastErrorShown !== errorKey) && status.lastError) { + this.showSyncError(status.lastError); + this.lastErrorShown = errorKey; + } + } + } + + showSyncError(error) { + // Don't spam notifications + if (document.querySelector('.sync-error-notification')) { + return; + } + + // Create and show error notification + const notification = document.createElement('div'); + notification.className = 'sync-error-notification'; + notification.innerHTML = ` +
+ โš ๏ธ Email Sync Error + +
+
+

The email list synchronization is not working:

+ ${this.escapeHtml(error || 'Connection failed')} +

New contacts will be saved locally but won't sync to email lists until this is resolved.

+

Please contact your administrator if this persists.

+
+ `; + + document.body.appendChild(notification); + + // Auto-remove after 15 seconds + setTimeout(() => { + if (notification && notification.parentNode) { + notification.remove(); + } + }, 15000); + } + + showReconnectedNotification() { + // Don't show if there's already a notification + if (document.querySelector('.sync-success-notification')) { + return; + } + + const notification = document.createElement('div'); + notification.className = 'sync-success-notification'; + notification.innerHTML = ` +
+ โœ… Email Sync Restored + +
+
+

Email list synchronization is working again. New contacts will now sync automatically.

+
+ `; + + document.body.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (notification && notification.parentNode) { + notification.remove(); + } + }, 5000); + } + + startStatusCheck() { + // Check immediately + this.checkStatus(); + + // Then check every 30 seconds + this.checkInterval = setInterval(() => { + this.checkStatus(); + }, 30000); + } + + stopStatusCheck() { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + } + + // Utility method for HTML escaping + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Manual refresh method for admin panel + async refresh() { + if (this.statusElement) { + this.statusElement.className = 'listmonk-status-indicator checking'; + this.statusElement.querySelector('.status-icon').innerHTML = 'โณ'; + } + await this.checkStatus(); + } + + // Get current status (for admin panel) + getCurrentStatus() { + return { ...this.currentStatus }; + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Only create if we don't already have one + if (!window.listmonkStatus) { + window.listmonkStatus = new ListmonkStatus(); + } +}); + +// Export for use in other modules +window.ListmonkStatus = ListmonkStatus; diff --git a/map/app/routes/admin.js b/map/app/routes/admin.js index a004fa8..787a3db 100644 --- a/map/app/routes/admin.js +++ b/map/app/routes/admin.js @@ -67,4 +67,45 @@ router.get('/nocodb-urls', (req, res) => { }); }); +// Get Listmonk URLs for admin panel +router.get('/listmonk-urls', (req, res) => { + console.log('Admin Listmonk URLs endpoint called'); + + // Construct Listmonk URLs using the public domain + // Since this route is protected by requireAdmin middleware, + // we know the user is an admin + const domain = process.env.DOMAIN; + let listmonkBaseUrl = null; + + if (domain) { + // Construct the public Listmonk URL using the domain + // Format: https://listmonk.{domain} + listmonkBaseUrl = `https://listmonk.${domain}`; + } + + const listmonkUrls = { + baseUrl: listmonkBaseUrl, + adminUrl: listmonkBaseUrl ? `${listmonkBaseUrl}/admin` : null, + listsUrl: listmonkBaseUrl ? `${listmonkBaseUrl}/admin/lists` : null, + campaignsUrl: listmonkBaseUrl ? `${listmonkBaseUrl}/admin/campaigns` : null, + subscribersUrl: listmonkBaseUrl ? `${listmonkBaseUrl}/admin/subscribers` : null, + settingsUrl: listmonkBaseUrl ? `${listmonkBaseUrl}/admin/settings` : null + }; + + console.log('Returning Listmonk URLs for admin:', { + domain: domain, + baseUrl: listmonkBaseUrl, + hasAdminUrl: !!listmonkUrls.adminUrl, + hasListsUrl: !!listmonkUrls.listsUrl, + hasCampaignsUrl: !!listmonkUrls.campaignsUrl, + hasSubscribersUrl: !!listmonkUrls.subscribersUrl, + hasSettingsUrl: !!listmonkUrls.settingsUrl + }); + + res.json({ + success: true, + listmonkUrls + }); +}); + module.exports = router; \ No newline at end of file diff --git a/map/app/routes/index.js b/map/app/routes/index.js index af7c36d..ea80ed5 100644 --- a/map/app/routes/index.js +++ b/map/app/routes/index.js @@ -14,6 +14,7 @@ const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding ro const shiftsRoutes = require('./shifts'); const externalDataRoutes = require('./external'); const cutsRoutes = require('./cuts'); +const listmonkRoutes = require('./listmonk'); module.exports = (app) => { // Health check (no auth) @@ -63,6 +64,9 @@ module.exports = (app) => { // Cuts routes (add after other protected routes) app.use('/api/cuts', requireAuth, cutsRoutes); + // Listmonk routes (authenticated) + app.use('/api/listmonk', requireAuth, listmonkRoutes); + // Admin routes app.get('/admin.html', requireAdmin, (req, res) => { res.sendFile(path.join(__dirname, '../public', 'admin.html')); diff --git a/map/app/routes/listmonk.js b/map/app/routes/listmonk.js new file mode 100644 index 0000000..e304049 --- /dev/null +++ b/map/app/routes/listmonk.js @@ -0,0 +1,25 @@ +const express = require('express'); +const router = express.Router(); +const listmonkController = require('../controllers/listmonkController'); +const { requireAuth, requireAdmin } = require('../middleware/auth'); + +// Get sync status (available to all authenticated users) +router.get('/status', requireAuth, listmonkController.getSyncStatus); + +// Test connection (admin only) +router.get('/test-connection', requireAdmin, listmonkController.testConnection); + +// Get list statistics (admin only) +router.get('/stats', requireAdmin, listmonkController.getListStats); + +// Bulk sync operations (admin only) +router.post('/sync/locations', requireAdmin, listmonkController.syncAllLocations); + +router.post('/sync/users', requireAdmin, listmonkController.syncAllUsers); + +router.post('/sync/all', requireAdmin, listmonkController.syncAll); + +// Reinitialize lists (admin only) +router.post('/reinitialize', requireAdmin, listmonkController.reinitializeLists); + +module.exports = router; diff --git a/map/app/server.js b/map/app/server.js index 8fcdcda..3c15f6d 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -29,6 +29,7 @@ const { getCookieConfig } = require('./utils/helpers'); const { apiLimiter } = require('./middleware/rateLimiter'); const { cacheBusting } = require('./utils/cacheBusting'); const { initializeEmailService } = require('./services/email'); +const listmonkService = require('./services/listmonk'); // Initialize Express app - only create once if (global.__expressApp) { @@ -177,6 +178,89 @@ if (!global.__emailInitialized) { global.__emailInitialized = true; } +// Initialize Listmonk service +if (!global.__listmonkInitialized) { + global.__listmonkInitialized = true; + + // Initialize Listmonk in background (don't block server startup) + setImmediate(async () => { + try { + if (listmonkService.syncEnabled) { + logger.info('๐Ÿ”„ Initializing Listmonk integration...'); + + const initialized = await listmonkService.initializeLists(); + + if (initialized) { + logger.info('โœ… Listmonk integration initialized successfully'); + + // Optional initial sync (only if explicitly enabled) + if (process.env.LISTMONK_INITIAL_SYNC === 'true') { + logger.info('๐Ÿ”„ Performing initial Listmonk sync...'); + + // Use setTimeout to further delay initial sync + setTimeout(async () => { + try { + const nocodbService = require('./services/nocodb'); + + // Sync existing locations + try { + const locationData = await nocodbService.getLocations(); + const locations = locationData?.list || []; + console.log('๐Ÿ” Initial sync - fetched locations:', locations?.length || 0); + if (locations && locations.length > 0) { + const locationResults = await listmonkService.bulkSync(locations, 'location'); + logger.info(`๐Ÿ“ Initial location sync: ${locationResults.success} succeeded, ${locationResults.failed} failed`); + } else { + logger.warn('No locations found for initial sync'); + } + } catch (locError) { + logger.warn('Initial location sync failed:', { + message: locError.message, + stack: locError.stack, + error: locError.toString() + }); + } + + // Sync existing users + try { + const userData = await nocodbService.getAllPaginated(config.nocodb.loginSheetId); + const users = userData?.list || []; + console.log('๐Ÿ” Initial sync - fetched users:', users?.length || 0); + if (users && users.length > 0) { + const userResults = await listmonkService.bulkSync(users, 'user'); + logger.info(`๐Ÿ‘ค Initial user sync: ${userResults.success} succeeded, ${userResults.failed} failed`); + } else { + logger.warn('No users found for initial sync'); + } + } catch (userError) { + logger.warn('Initial user sync failed:', { + message: userError.message, + stack: userError.stack, + error: userError.toString() + }); + } + + logger.info('โœ… Initial Listmonk sync completed'); + } catch (syncError) { + logger.error('Initial sync failed:', syncError.message); + } + }, 5000); // Wait 5 seconds after startup + } + } else { + logger.error('โŒ Listmonk integration failed to initialize'); + logger.error(`Last error: ${listmonkService.lastError}`); + } + } else { + logger.info('๐Ÿ“ง Listmonk sync is disabled via configuration'); + } + } catch (error) { + logger.error('Listmonk initialization error:', error.message); + // Don't crash the app, just disable sync + listmonkService.syncEnabled = false; + } + }); +} + // Import and setup routes require('./routes')(app); diff --git a/map/app/services/listmonk.js b/map/app/services/listmonk.js new file mode 100644 index 0000000..d331d72 --- /dev/null +++ b/map/app/services/listmonk.js @@ -0,0 +1,513 @@ +const axios = require('axios'); +const logger = require('../utils/logger'); + +class ListmonkService { + constructor() { + this.baseURL = process.env.LISTMONK_API_URL || 'http://listmonk:9000/api'; + this.username = process.env.LISTMONK_USERNAME; + this.password = process.env.LISTMONK_PASSWORD; + this.lists = { + locations: null, + users: null, + supportLevel1: null, + supportLevel2: null, + supportLevel3: null, + supportLevel4: null, + hasSign: null, + wantsSign: null, + signRegular: null, + signLarge: null, + signUnsure: null + }; + + // Debug logging for environment variables + console.log('๐Ÿ” Listmonk Environment Variables:'); + console.log(` LISTMONK_SYNC_ENABLED: ${process.env.LISTMONK_SYNC_ENABLED}`); + console.log(` LISTMONK_API_URL: ${process.env.LISTMONK_API_URL}`); + console.log(` LISTMONK_USERNAME: ${this.username ? 'SET' : 'NOT SET'}`); + console.log(` LISTMONK_PASSWORD: ${this.password ? 'SET' : 'NOT SET'}`); + + this.syncEnabled = process.env.LISTMONK_SYNC_ENABLED === 'true'; + + // Additional validation - disable if credentials are missing + if (this.syncEnabled && (!this.username || !this.password)) { + logger.warn('Listmonk credentials missing - disabling sync'); + this.syncEnabled = false; + } + + console.log(` Final syncEnabled: ${this.syncEnabled}`); + + this.lastError = null; + this.lastErrorTime = null; + } + + // Create axios instance with auth + getClient() { + return axios.create({ + baseURL: this.baseURL, + auth: { + username: this.username, + password: this.password + }, + headers: { + 'Content-Type': 'application/json' + }, + timeout: 10000 // 10 second timeout + }); + } + + // Test connection to Listmonk + async checkConnection() { + if (!this.syncEnabled) { + return false; + } + + try { + console.log(`๐Ÿ” Testing connection to: ${this.baseURL}`); + console.log(`๐Ÿ” Using credentials: ${this.username}:${this.password ? 'SET' : 'NOT SET'}`); + + const client = this.getClient(); + console.log('๐Ÿ” Making request to /health endpoint...'); + const { data } = await client.get('/health'); + + console.log('๐Ÿ” Response received:', JSON.stringify(data, null, 2)); + + if (data.data === true) { + logger.info('Listmonk connection successful'); + this.lastError = null; + this.lastErrorTime = null; + return true; + } + console.log('๐Ÿ” Health check failed - data.data is not true'); + return false; + } catch (error) { + console.log('๐Ÿ” Connection error details:', error.message); + if (error.response) { + console.log('๐Ÿ” Response status:', error.response.status); + console.log('๐Ÿ” Response data:', error.response.data); + } + this.lastError = `Listmonk connection failed: ${error.message}`; + this.lastErrorTime = new Date(); + logger.error(this.lastError); + return false; + } + } + + // Initialize all lists on startup + async initializeLists() { + if (!this.syncEnabled) { + logger.info('Listmonk sync is disabled'); + return false; + } + + try { + // Check connection first + const connected = await this.checkConnection(); + if (!connected) { + // Use the actual error message from checkConnection + throw new Error(`Cannot connect to Listmonk: ${this.lastError || 'Unknown connection error'}`); + } + + // Create or get main lists + this.lists.locations = await this.ensureList({ + name: 'Map Locations - All', + type: 'private', + optin: 'single', + tags: ['map', 'locations', 'automated'], + description: 'All locations from the map application' + }); + + this.lists.users = await this.ensureList({ + name: 'Map Users - All', + type: 'private', + optin: 'single', + tags: ['map', 'users', 'automated'], + description: 'All registered users from the map application' + }); + + // Create support level lists + this.lists.supportLevel1 = await this.ensureList({ + name: 'Support Level 1', + type: 'private', + optin: 'single', + tags: ['map', 'support-level-1', 'automated'], + description: 'Strong supporters (Level 1 - Green)' + }); + + this.lists.supportLevel2 = await this.ensureList({ + name: 'Support Level 2', + type: 'private', + optin: 'single', + tags: ['map', 'support-level-2', 'automated'], + description: 'Moderate supporters (Level 2 - Yellow)' + }); + + this.lists.supportLevel3 = await this.ensureList({ + name: 'Support Level 3', + type: 'private', + optin: 'single', + tags: ['map', 'support-level-3', 'automated'], + description: 'Weak supporters (Level 3 - Orange)' + }); + + this.lists.supportLevel4 = await this.ensureList({ + name: 'Support Level 4', + type: 'private', + optin: 'single', + tags: ['map', 'support-level-4', 'automated'], + description: 'Opponents (Level 4 - Red)' + }); + + // Create sign status lists + this.lists.hasSign = await this.ensureList({ + name: 'Has Campaign Sign', + type: 'private', + optin: 'single', + tags: ['map', 'has-sign', 'automated'], + description: 'Locations with campaign signs' + }); + + this.lists.wantsSign = await this.ensureList({ + name: 'Sign Requests', + type: 'private', + optin: 'single', + tags: ['map', 'sign-requests', 'automated'], + description: 'Supporters without signs (potential sign placements)' + }); + + // Create sign size lists + this.lists.signRegular = await this.ensureList({ + name: 'Regular Signs', + type: 'private', + optin: 'single', + tags: ['map', 'sign-regular', 'automated'], + description: 'Locations with regular-sized campaign signs' + }); + + this.lists.signLarge = await this.ensureList({ + name: 'Large Signs', + type: 'private', + optin: 'single', + tags: ['map', 'sign-large', 'automated'], + description: 'Locations with large-sized campaign signs' + }); + + this.lists.signUnsure = await this.ensureList({ + name: 'Sign Size Unsure', + type: 'private', + optin: 'single', + tags: ['map', 'sign-unsure', 'automated'], + description: 'Locations with unsure sign size preferences' + }); + + logger.info('โœ… Listmonk lists initialized successfully'); + return true; + + } catch (error) { + this.lastError = `Failed to initialize Listmonk lists: ${error.message}`; + this.lastErrorTime = new Date(); + logger.error(this.lastError); + return false; + } + } + + // Ensure a list exists, create if not + async ensureList(listConfig) { + try { + const client = this.getClient(); + + // First, try to find existing list by name + const { data: listsResponse } = await client.get('/lists'); + const existingList = listsResponse.data.results.find(list => list.name === listConfig.name); + + if (existingList) { + logger.info(`๐Ÿ“‹ Found existing list: ${listConfig.name}`); + return existingList; + } + + // Create new list + const { data: createResponse } = await client.post('/lists', listConfig); + logger.info(`๐Ÿ“‹ Created new list: ${listConfig.name}`); + return createResponse.data; + + } catch (error) { + logger.error(`Failed to ensure list ${listConfig.name}:`, error.message); + throw error; + } + } + + // Sync a location to Listmonk + async syncLocation(locationData) { + console.log('๐Ÿ” syncLocation called with:', { + hasEmail: !!locationData.Email, + email: locationData.Email, + syncEnabled: this.syncEnabled, + firstName: locationData['First Name'], + lastName: locationData['Last Name'], + supportLevel: locationData['Support Level'], + sign: locationData.Sign, + signType: typeof locationData.Sign, + signValue: String(locationData.Sign), + signRequested: locationData['Sign Requested'], + wantsSign: locationData['Wants Sign'] + }); + + // Let's also see all keys in the locationData to see if the field name is different + console.log('๐Ÿ” All locationData keys:', Object.keys(locationData)); + console.log('๐Ÿ” Sign-related fields check:', { + 'Sign': locationData.Sign, + 'Sign Size': locationData['Sign Size'], + 'sign': locationData.sign, + 'Campaign Sign': locationData['Campaign Sign'], + 'Sign Type': locationData['Sign Type'], + 'Has Sign': locationData['Has Sign'], + 'Yard Sign': locationData['Yard Sign'] + }); + + if (!this.syncEnabled || !locationData.Email) { + console.log('๐Ÿ” Sync disabled or no email - returning failure'); + return { success: false, error: 'Sync disabled or no email provided' }; + } + + try { + // Check if they have a sign - based on Sign Size field having a value + const signSize = locationData['Sign Size']; + const hasSignValue = signSize && signSize !== '' && signSize !== null && signSize !== 'null'; + + console.log('๐Ÿ” Sign detection:', { + 'Sign Size': signSize, + 'Sign Size Type': typeof signSize, + 'Has Sign Value': hasSignValue, + 'Sign Boolean': locationData.Sign + }); + + const subscriberData = { + email: locationData.Email, + name: `${locationData['First Name'] || ''} ${locationData['Last Name'] || ''}`.trim(), + status: 'enabled', + lists: [this.lists.locations.id], + attribs: { + address: locationData.Address, + support_level: locationData['Support Level'], + has_sign: locationData.Sign === true, + sign_type: signSize || null, + sign_boolean: locationData.Sign, + unit_number: locationData['Unit Number'], + phone: locationData.Phone, + notes: locationData.Notes + } + }; + + // Add to support level lists + const supportLevel = locationData['Support Level']; + if (supportLevel && this.lists[`supportLevel${supportLevel}`]) { + subscriberData.lists.push(this.lists[`supportLevel${supportLevel}`].id); + } + + // Add to sign status lists + // 1. Has Campaign Sign = only if Sign boolean is true (they physically have a sign) + if (locationData.Sign === true) { + subscriberData.lists.push(this.lists.hasSign.id); + console.log('๐Ÿ” Added to hasSign list because Sign boolean is true'); + } + + // 2. Sign Requests = if they selected a sign size but Sign boolean is false (want sign, don't have it yet) + if (hasSignValue && locationData.Sign !== true) { + subscriberData.lists.push(this.lists.wantsSign.id); + console.log('๐Ÿ” Added to wantsSign list because they selected sign size but Sign boolean is false'); + } + + // 3. Add to specific sign size lists based on Sign Size selection + if (hasSignValue) { + const signSizeLower = signSize.toLowerCase(); + if (signSizeLower === 'regular' && this.lists.signRegular) { + subscriberData.lists.push(this.lists.signRegular.id); + console.log('๐Ÿ” Added to signRegular list'); + } else if (signSizeLower === 'large' && this.lists.signLarge) { + subscriberData.lists.push(this.lists.signLarge.id); + console.log('๐Ÿ” Added to signLarge list'); + } else if (signSizeLower === 'unsure' && this.lists.signUnsure) { + subscriberData.lists.push(this.lists.signUnsure.id); + console.log('๐Ÿ” Added to signUnsure list'); + } + } + + const client = this.getClient(); + + // Check if subscriber already exists + try { + const { data: existingResponse } = await client.get(`/subscribers?query=${encodeURIComponent(`email = '${locationData.Email}'`)}`); + if (existingResponse.data.results && existingResponse.data.results.length > 0) { + // Update existing subscriber + const subscriberId = existingResponse.data.results[0].id; + await client.put(`/subscribers/${subscriberId}`, subscriberData); + logger.debug(`๐Ÿ“ Updated location subscriber: ${locationData.Email}`); + } else { + // Create new subscriber + await client.post('/subscribers', subscriberData); + logger.debug(`๐Ÿ“ Created location subscriber: ${locationData.Email}`); + } + console.log('๐Ÿ” Location sync successful - returning success'); + return { success: true }; + } catch (subscriptionError) { + console.log('๐Ÿ” Subscriber search failed, trying to create new:', subscriptionError.message); + // If subscriber doesn't exist, create new one + await client.post('/subscribers', subscriberData); + logger.debug(`๐Ÿ“ Created location subscriber: ${locationData.Email}`); + console.log('๐Ÿ” Location sync successful (fallback) - returning success'); + return { success: true }; + } + + } catch (error) { + console.log('๐Ÿ” Location sync failed with error:', error.message); + logger.error(`Failed to sync location ${locationData.Email}:`, error.message); + return { success: false, error: error.message }; + } + } + + // Sync a user to Listmonk + async syncUser(userData) { + if (!this.syncEnabled || !userData.Email) { + return { success: false, error: 'Sync disabled or no email provided' }; + } + + try { + const subscriberData = { + email: userData.Email, + name: userData.Name || userData.Email, + status: 'enabled', + lists: [this.lists.users.id], + attribs: { + user_type: userData.UserType || 'user', + admin: userData.Admin || false, + created_at: userData['Created At'] || userData.created_at, + last_login: userData['Last Login'] || userData.last_login + } + }; + + const client = this.getClient(); + + // Check if subscriber already exists + try { + const { data: existingResponse } = await client.get(`/subscribers?query=${encodeURIComponent(`email = '${userData.Email}'`)}`); + if (existingResponse.data.results && existingResponse.data.results.length > 0) { + // Update existing subscriber + const subscriberId = existingResponse.data.results[0].id; + await client.put(`/subscribers/${subscriberId}`, subscriberData); + logger.debug(`๐Ÿ‘ค Updated user subscriber: ${userData.Email}`); + } else { + // Create new subscriber + await client.post('/subscribers', subscriberData); + logger.debug(`๐Ÿ‘ค Created user subscriber: ${userData.Email}`); + } + return { success: true }; + } catch (subscriptionError) { + // If subscriber doesn't exist, create new one + await client.post('/subscribers', subscriberData); + logger.debug(`๐Ÿ‘ค Created user subscriber: ${userData.Email}`); + return { success: true }; + } + + } catch (error) { + logger.error(`Failed to sync user ${userData.Email}:`, error.message); + return { success: false, error: error.message }; + } + } + + // Remove subscriber from Listmonk + async removeSubscriber(email) { + if (!this.syncEnabled || !email) { + return { success: false, error: 'Sync disabled or no email provided' }; + } + + try { + const client = this.getClient(); + const { data: response } = await client.get(`/subscribers?query=${encodeURIComponent(`email = '${email}'`)}`); + + if (response.data.results && response.data.results.length > 0) { + const subscriberId = response.data.results[0].id; + await client.delete(`/subscribers/${subscriberId}`); + logger.debug(`๐Ÿ—‘๏ธ Removed subscriber: ${email}`); + return { success: true }; + } + + return { success: false, error: 'Subscriber not found' }; + } catch (error) { + logger.error(`Failed to remove subscriber ${email}:`, error.message); + return { success: false, error: error.message }; + } + } + + // Bulk sync multiple records + async bulkSync(records, type) { + if (!this.syncEnabled || !records || records.length === 0) { + return { success: 0, failed: 0 }; + } + + let successCount = 0; + let failedCount = 0; + + for (const record of records) { + try { + let result; + if (type === 'location') { + result = await this.syncLocation(record); + } else if (type === 'user') { + result = await this.syncUser(record); + } + + if (result && result.success) { + successCount++; + } else { + failedCount++; + } + } catch (error) { + failedCount++; + logger.error(`Bulk sync failed for record:`, error.message); + } + } + + logger.info(`๐Ÿ“Š Bulk sync completed: ${successCount} success, ${failedCount} failed`); + return { success: successCount, failed: failedCount }; + } + + // Get sync status + getSyncStatus() { + return { + enabled: this.syncEnabled, + connected: this.lastError === null, + lastError: this.lastError, + lastErrorTime: this.lastErrorTime, + listsInitialized: Object.values(this.lists).some(list => list !== null) + }; + } + + // Get list statistics + async getListStats() { + if (!this.syncEnabled) return null; + + try { + const client = this.getClient(); + const { data } = await client.get('/lists'); + + const stats = {}; + for (const [key, list] of Object.entries(this.lists)) { + if (list) { + const listData = data.data.results.find(l => l.id === list.id); + stats[key] = { + name: list.name, + subscriber_count: listData ? listData.subscriber_count : 0 + }; + } + } + + return stats; + } catch (error) { + logger.error('Failed to get list stats:', error.message); + return null; + } + } +} + +// Export singleton instance +module.exports = new ListmonkService(); diff --git a/map/files-explainer.md b/map/files-explainer.md index 9d5454d..d5063ee 100644 --- a/map/files-explainer.md +++ b/map/files-explainer.md @@ -88,6 +88,10 @@ Controller for user management (list, create, delete users, send login details v Controller for CRUD operations on map cuts (geographic polygon overlays). Handles cut creation, editing, deletion, and visibility management with admin-only access for modifications and public access for viewing public cuts. +# app/controllers/listmonkController.js + +Controller for managing Listmonk email list synchronization. Handles sync status checking, bulk synchronization operations for locations and users, list statistics retrieval, connection testing, and list reinitialization. Provides admin-only endpoints for managing the email marketing integration. + # app/middleware/auth.js Express middleware for authentication and admin access control. @@ -120,6 +124,10 @@ Service for loading and rendering email templates with variable substitution. Ha Service for geocoding and reverse geocoding using external APIs, with caching. Updated to include forwardGeocodeSearch function for returning multiple address search results for the unified search feature. +# app/services/listmonk.js + +Service for integrating with Listmonk email marketing platform. Handles API communication, list management, subscriber synchronization, and automatic segmentation. Creates and manages email lists for locations (segmented by support level, sign status) and users, with real-time sync capabilities and bulk operations support. + # app/services/nocodb.js Service for interacting with the NocoDB API (CRUD, config, etc). @@ -174,7 +182,7 @@ Winston logger configuration for backend logging. # app/public/admin.html -Admin panel HTML page for managing start location, walk sheet, shift management, user management, and email broadcasting. Features rich text editor with live preview for composing broadcast emails, shift volunteer management modals, and comprehensive admin interface with user role controls. +Admin panel HTML page for managing start location, walk sheet, shift management, user management, and email broadcasting. Features rich text editor with live preview for composing broadcast emails, shift volunteer management modals, comprehensive admin interface with user role controls, and quick access links to both NocoDB database management and Listmonk email marketing interfaces. # app/public/css/admin.css @@ -228,6 +236,10 @@ Defines the main application layout, including the header, app container, and ma Customizations for the Leaflet.js library, including popups and marker styles. +# app/public/css/modules/listmonk.css + +Styles for Listmonk email sync status indicators, notifications, and admin panel interface. Includes status badges, error/success notifications, progress bars, sync controls, and responsive design for email list management features. + # app/public/css/modules/map-controls.css Styles for map controls, such as the crosshairs and move controls. @@ -302,7 +314,7 @@ CSS styles for the user profile page and user management components in the admin # app/public/js/admin.js -JavaScript for admin panel functionality (map, start location, walk sheet, shift management, user management, and email broadcasting). Includes rich text editor for composing emails with live preview, shift volunteer management with modal interface, mass email broadcasting to all users, shift detail emailing to volunteers, and comprehensive admin controls. +JavaScript for admin panel functionality (map, start location, walk sheet, shift management, user management, and email broadcasting). Includes rich text editor for composing emails with live preview, shift volunteer management with modal interface, mass email broadcasting to all users, shift detail emailing to volunteers, comprehensive admin controls, and automatic initialization of both NocoDB database links and Listmonk email management links. # app/public/js/dashboard.js @@ -376,6 +388,14 @@ JavaScript module for cut display controls on the public map. Handles loading an JavaScript for the admin cut management interface. Provides complete CRUD functionality for cuts including interactive drawing, form management, cut list display, and import/export capabilities. +# app/public/js/listmonk-status.js + +JavaScript module for real-time Listmonk sync status monitoring. Displays connection status indicators in the UI, shows error notifications when sync fails, handles automatic status checking, and provides user feedback for email list synchronization health. + +# app/public/js/listmonk-admin.js + +JavaScript for admin panel Listmonk management functionality. Handles bulk synchronization operations, progress tracking, list statistics display, connection testing, and admin interface interactions for email list management. Integrates with the admin panel UI for comprehensive Listmonk control. + # app/public/js/auth.js JavaScript for authentication logic and user session management. Includes temporary user restrictions, role-based UI visibility controls, and dynamic user interface updates based on user type and admin status. @@ -418,7 +438,7 @@ JavaScript module for managing external data layers on the map. Handles loading # app/routes/admin.js -Express router for admin-only endpoints (start location, walk sheet config). +Express router for admin-only endpoints (start location, walk sheet config, NocoDB URLs, and Listmonk URLs). Provides secure access to both database management and email marketing platform URLs for admin users. Constructs Listmonk URLs using the public domain format (https://listmonk.domain) for proper external access. # app/routes/dashboard.js @@ -444,6 +464,10 @@ Main router that mounts all sub-routes for the backend API. Express router for CRUD endpoints on map locations. +# app/routes/listmonk.js + +Express router for Listmonk email list management endpoints. Provides authenticated and admin-only routes for sync status checking, bulk synchronization operations, list statistics, connection testing, and list reinitialization. Includes proper authorization middleware to restrict admin functions. + # app/routes/qr.js Express router for QR code generation endpoints. @@ -476,3 +500,7 @@ Express router for external data integration endpoints, including Socrata API in Utility for managing cache busting functionality to ensure users get the latest version of the application when updates are deployed. Handles versioning and cache invalidation strategies. +# listmonk-env-example.txt + +Example environment configuration file showing the required Listmonk environment variables. Provides sample configuration for API URL, credentials, sync settings, and setup instructions for integrating with the Listmonk email marketing platform. + diff --git a/map/instruct/LISTMONK_INTEGRATION_GUIDE.md b/map/instruct/LISTMONK_INTEGRATION_GUIDE.md new file mode 100644 index 0000000..da6e0be --- /dev/null +++ b/map/instruct/LISTMONK_INTEGRATION_GUIDE.md @@ -0,0 +1,318 @@ +# Listmonk Integration Guide + +This guide covers the complete setup and usage of the Listmonk integration for the Map application. + +## Overview + +The Map application integrates seamlessly with [Listmonk](https://listmonk.app/), a self-hosted newsletter and mailing list manager, to provide advanced email marketing capabilities. This integration enables: + +- **Real-time subscriber synchronization** +- **Automated list segmentation** +- **Professional email campaigns** +- **Advanced analytics and reporting** +- **Bi-directional data sync** + +## Prerequisites + +1. **Listmonk Instance**: You need a running Listmonk instance (typically deployed via Docker Compose) +2. **Admin Access**: Listmonk admin credentials for API access +3. **Network Connectivity**: Map application must be able to reach Listmonk instance +4. **Environment Configuration**: Proper `.env` setup + +## Quick Setup + +### 1. Configure Environment Variables + +Add these variables to your `.env` file: + +```env +# Listmonk Integration +LISTMONK_URL=http://listmonk:9000 +LISTMONK_USERNAME=admin +LISTMONK_PASSWORD=your-secure-password +LISTMONK_ENABLED=true +LISTMONK_SYNC_ON_STARTUP=true +LISTMONK_AUTO_CREATE_LISTS=true +``` + +**Important Notes:** +- Use `http://listmonk:9000` for Docker Compose deployments (service name) +- Use `http://localhost:9000` for local development +- Ensure the password matches your Listmonk admin password + +### 2. Restart the Application + +```bash +docker-compose restart map-viewer +``` + +### 3. Verify Integration + +1. **Check Admin Panel**: Go to `/admin.html` and look for the "Listmonk Integration" section +2. **Status Indicator**: Green status means successful connection +3. **View Logs**: Check terminal for sync messages +4. **Listmonk Dashboard**: Verify lists were created in Listmonk + +## Automated List Management + +The integration automatically creates and maintains these subscriber lists: + +### Core Lists +- **All Locations** - Every location with contact information +- **All Users** - Every registered Map application user + +### Support Level Lists +- **Support Level 1** - Strongest supporters (Green markers) +- **Support Level 2** - Moderate supporters (Yellow markers) +- **Support Level 3** - Weak supporters (Orange markers) +- **Support Level 4** - Opponents (Red markers) + +### Sign Status Lists +- **Has Sign** - Locations with campaign signs +- **No Sign** - Locations without signs + +### Combined Segmentation Lists +- **Support 1 with Signs** - Strong supporters who have signs +- **Support 2 with Signs** - Moderate supporters who have signs +- **Support 3 with Signs** - Weak supporters who have signs +- **Support 4 with Signs** - Opponents who have signs (for tracking) +- **Support 1-3** - All positive supporters (Levels 1, 2, and 3) +- **Sign Requests** - Supporters without signs (potential sign placements) + +## Real-Time Sync Features + +### Automatic Synchronization +- **New Location Added**: Instantly added to appropriate lists +- **Location Updated**: Support level or sign status changes trigger list updates +- **Location Deleted**: Removed from all lists +- **User Created**: Added to "All Users" list +- **User Deleted**: Removed from all lists + +### Bi-Directional Sync +- **Unsubscribes**: When users unsubscribe in Listmonk, their records are updated in the Map database +- **Email Updates**: Email address changes sync both ways +- **List Management**: Changes in Listmonk are reflected in Map data + +## Admin Management Features + +### Status Dashboard +- **Connection Status**: Live indicator (๐ŸŸข Connected, ๐Ÿ”ด Error, โšช Disabled) +- **Sync Statistics**: Total subscribers, lists, and sync operations +- **Last Sync Times**: When each data type was last synchronized +- **Error Notifications**: Pop-up alerts for sync failures + +### Manual Operations +- **Full Resync**: Completely rebuild all lists from current data +- **Selective Sync**: Sync only locations or users +- **Test Connection**: Verify Listmonk connectivity +- **Reinitialize**: Delete and recreate all lists +- **Progress Tracking**: Visual progress bars for bulk operations + +### Bulk Sync Controls +```javascript +// Available admin actions +POST /api/listmonk/sync/full // Complete resync +POST /api/listmonk/sync/locations // Sync locations only +POST /api/listmonk/sync/users // Sync users only +POST /api/listmonk/test-connection // Test connectivity +POST /api/listmonk/reinitialize // Recreate lists +``` + +## Error Handling & Troubleshooting + +### Common Issues + +#### Connection Failed +**Symptoms**: Red status indicator, "Connection failed" in logs +**Solutions**: +1. Verify `LISTMONK_URL` is correct +2. Check Listmonk is running: `docker-compose ps` +3. Test network connectivity: `curl http://listmonk:9000` +4. Verify credentials in Listmonk admin panel + +#### Sync Errors +**Symptoms**: Yellow status indicator, specific error messages in logs +**Solutions**: +1. Check Listmonk API limits and performance +2. Verify subscriber data format (valid emails, etc.) +3. Review Listmonk logs: `docker-compose logs listmonk` +4. Try manual resync from admin panel + +#### Missing Lists +**Symptoms**: Lists not appearing in Listmonk +**Solutions**: +1. Ensure `LISTMONK_AUTO_CREATE_LISTS=true` +2. Restart application to trigger initialization +3. Use "Reinitialize" button in admin panel +4. Check Listmonk permissions and API access + +#### Slow Sync Performance +**Symptoms**: Long delays during sync operations +**Solutions**: +1. Monitor Listmonk server resources +2. Check network latency between services +3. Consider reducing sync frequency for large datasets +4. Review database performance (both NocoDB and Listmonk) + +### Debug Logging + +Enable detailed logging by setting: +```env +NODE_ENV=development +``` + +This provides verbose sync operation details in the terminal. + +## Email Campaign Workflow + +### 1. Segment Selection +Use the automatically created lists in Listmonk to target specific audience segments: +- **Fundraising**: Target "Support Level 1" for donation requests +- **Volunteer Recruitment**: Target "Support 1-3" for volunteer opportunities +- **Sign Placement**: Target "Sign Requests" for new sign installations +- **Opposition Research**: Monitor "Support Level 4" engagement + +### 2. Campaign Creation +Create campaigns in Listmonk using the segmented lists: +1. Go to Listmonk admin panel +2. Create new campaign +3. Select appropriate subscriber lists +4. Design email content +5. Schedule or send immediately + +### 3. Analytics & Follow-up +- **Track Opens**: Monitor engagement by support level +- **Click Tracking**: Measure action rates across segments +- **Unsubscribe Monitoring**: Identify potential support level changes +- **A/B Testing**: Test messaging effectiveness across segments + +## Advanced Configuration + +### Custom List Creation +To create additional custom lists, modify the Listmonk service configuration: + +```javascript +// app/services/listmonk.js +const customLists = [ + { + name: 'High Value Voters', + description: 'Support Level 1 & 2 with voting history', + // Add custom segmentation logic + } +]; +``` + +### Sync Frequency Tuning +Adjust sync timing based on your needs: + +```env +# Sync immediately on data changes (default: true) +LISTMONK_REALTIME_SYNC=true + +# Startup sync (default: true) +LISTMONK_SYNC_ON_STARTUP=true + +# Batch size for large syncs (default: 100) +LISTMONK_BATCH_SIZE=50 +``` + +### Webhook Integration +For advanced use cases, consider setting up Listmonk webhooks to trigger Map application updates: + +```env +# Enable webhook endpoint +LISTMONK_WEBHOOK_ENABLED=true +LISTMONK_WEBHOOK_SECRET=your-webhook-secret +``` + +## Security Considerations + +### Credentials Management +- **Store securely**: Use environment variables, never commit passwords +- **Rotate regularly**: Change Listmonk passwords periodically +- **Limit access**: Create dedicated API user with minimal required permissions +- **Network security**: Use HTTPS in production, restrict network access + +### Data Privacy +- **GDPR Compliance**: Ensure proper consent and unsubscribe handling +- **Data Minimization**: Only sync necessary contact information +- **Retention Policies**: Configure appropriate data retention in Listmonk +- **Audit Logging**: Monitor access and changes to subscriber data + +## Integration Monitoring + +### Health Checks +The system provides several monitoring endpoints: + +```javascript +GET /api/listmonk/status // Connection and sync status +GET /api/listmonk/lists // List information and counts +GET /health // Overall application health +``` + +### Performance Metrics +Monitor these key metrics: +- **Sync Duration**: Time to complete full synchronization +- **Error Rate**: Percentage of failed sync operations +- **List Growth**: Subscriber count changes over time +- **API Response Time**: Listmonk API performance + +### Alerting +Set up monitoring for: +- **Connection failures** (implement external monitoring) +- **Sync errors** (check application logs) +- **List count discrepancies** (compare Map vs Listmonk counts) +- **Performance degradation** (sync time increases) + +## Backup & Recovery + +### Data Backup +- **Listmonk Data**: Regular database backups of Listmonk +- **Map Data**: NocoDB backup includes all source data +- **Configuration**: Version control `.env` files (without secrets) + +### Disaster Recovery +In case of data loss: +1. **Restore Listmonk**: From database backup +2. **Reinitialize**: Use admin panel "Reinitialize" function +3. **Full Resync**: Rebuild all lists from Map data +4. **Verify**: Check list counts and subscriber data + +## Best Practices + +### Campaign Management +- **Consistent Messaging**: Maintain brand voice across all campaigns +- **Segmentation Strategy**: Use meaningful, actionable segments +- **Testing**: A/B test subject lines and content +- **Compliance**: Follow CAN-SPAM and GDPR requirements +- **Analytics**: Track and analyze campaign performance + +### Data Quality +- **Email Validation**: Verify email addresses before adding to lists +- **Duplicate Prevention**: System handles duplicates automatically +- **Regular Cleanup**: Remove bounced and inactive subscribers +- **Contact Updates**: Keep contact information current + +### Performance Optimization +- **Batch Operations**: Use bulk sync for large data changes +- **Off-Peak Sync**: Schedule heavy operations during low-usage periods +- **Resource Monitoring**: Watch server resources during sync operations +- **Network Optimization**: Ensure reliable connectivity between services + +## Support & Documentation + +### Additional Resources +- **Listmonk Documentation**: https://listmonk.app/docs/ +- **Map Application Docs**: See main README.md +- **API Reference**: Available at `/api` endpoints +- **Community Support**: GitHub issues and discussions + +### Getting Help +If you encounter issues: +1. **Check Status**: Admin panel shows current integration status +2. **Review Logs**: Terminal output provides detailed error information +3. **Test Connection**: Use admin panel connection test +4. **Documentation**: Refer to this guide and Listmonk docs +5. **Community**: Ask questions in project GitHub issues + +This integration significantly enhances the Map application's email marketing capabilities while maintaining data consistency and providing powerful segmentation options for targeted campaigns. diff --git a/map/listmonk-env-example.txt b/map/listmonk-env-example.txt new file mode 100644 index 0000000..09cada8 --- /dev/null +++ b/map/listmonk-env-example.txt @@ -0,0 +1,14 @@ +# Add these Listmonk configuration variables to your .env file + +# Listmonk Configuration +LISTMONK_API_URL=http://listmonk:9000/api +LISTMONK_USERNAME=admin +LISTMONK_PASSWORD=your-secure-listmonk-password +LISTMONK_SYNC_ENABLED=true +LISTMONK_INITIAL_SYNC=false # Set to true only for first run to sync existing data + +# Note: Make sure to: +# 1. Replace 'your-secure-listmonk-password' with your actual Listmonk admin password +# 2. Update the LISTMONK_API_URL if your Listmonk instance runs on a different host/port +# 3. Set LISTMONK_INITIAL_SYNC=true only once to sync existing data, then set back to false +# 4. Restart the Map application after updating these variables diff --git a/map/test-listmonk-integration.sh b/map/test-listmonk-integration.sh new file mode 100755 index 0000000..87357d4 --- /dev/null +++ b/map/test-listmonk-integration.sh @@ -0,0 +1,293 @@ +#!/bin/bash + +# Listmonk Integration Test Script +# This script validates the complete Listmonk integration + +echo "๐Ÿงช Starting Listmonk Integration Tests..." +echo "=======================================" + +# Configuration +MAP_URL="http://localhost:3000" +LISTMONK_URL="http://localhost:9000" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test counter +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Helper functions +pass_test() { + echo -e "${GREEN}โœ… PASS:${NC} $1" + ((TESTS_PASSED++)) +} + +fail_test() { + echo -e "${RED}โŒ FAIL:${NC} $1" + ((TESTS_FAILED++)) +} + +warn_test() { + echo -e "${YELLOW}โš ๏ธ WARN:${NC} $1" +} + +info_test() { + echo -e "${BLUE}โ„น๏ธ INFO:${NC} $1" +} + +# Test 1: Environment Configuration +echo +echo "๐Ÿ“‹ Test 1: Environment Configuration" +echo "------------------------------------" + +if grep -q "LISTMONK_ENABLED=true" .env 2>/dev/null; then + pass_test "Listmonk is enabled in environment" +else + fail_test "LISTMONK_ENABLED not set to true in .env" +fi + +if grep -q "LISTMONK_URL=" .env 2>/dev/null; then + LISTMONK_ENV_URL=$(grep "LISTMONK_URL=" .env | cut -d'=' -f2) + pass_test "Listmonk URL configured: $LISTMONK_ENV_URL" +else + fail_test "LISTMONK_URL not configured in .env" +fi + +if grep -q "LISTMONK_USERNAME=" .env 2>/dev/null; then + pass_test "Listmonk username configured" +else + fail_test "LISTMONK_USERNAME not configured in .env" +fi + +# Test 2: Service Connectivity +echo +echo "๐ŸŒ Test 2: Service Connectivity" +echo "------------------------------" + +# Test Map application +if curl -s "$MAP_URL/health" > /dev/null 2>&1; then + pass_test "Map application is accessible at $MAP_URL" +else + fail_test "Map application not accessible at $MAP_URL" +fi + +# Test Listmonk service +if curl -s "$LISTMONK_URL/api/health" > /dev/null 2>&1; then + pass_test "Listmonk service is accessible at $LISTMONK_URL" +else + fail_test "Listmonk service not accessible at $LISTMONK_URL" +fi + +# Test 3: File Structure +echo +echo "๐Ÿ“ Test 3: Integration File Structure" +echo "------------------------------------" + +REQUIRED_FILES=( + "app/services/listmonk.js" + "app/controllers/listmonkController.js" + "app/routes/listmonk.js" + "app/public/js/listmonk-status.js" + "app/public/js/listmonk-admin.js" + "app/public/css/modules/listmonk.css" + "listmonk-env-example.txt" + "instruct/LISTMONK_INTEGRATION_GUIDE.md" +) + +for file in "${REQUIRED_FILES[@]}"; do + if [[ -f "$file" ]]; then + pass_test "Required file exists: $file" + else + fail_test "Missing required file: $file" + fi +done + +# Test 4: Code Integration Points +echo +echo "๐Ÿ”— Test 4: Code Integration Points" +echo "---------------------------------" + +# Check if listmonk service is imported in server.js +if grep -q "listmonk" app/server.js 2>/dev/null; then + pass_test "Listmonk service integrated in server.js" +else + fail_test "Listmonk service not integrated in server.js" +fi + +# Check if real-time sync hooks are in controllers +if grep -q "listmonkService" app/controllers/locationsController.js 2>/dev/null; then + pass_test "Real-time sync integrated in locations controller" +else + fail_test "Real-time sync missing from locations controller" +fi + +if grep -q "listmonkService" app/controllers/usersController.js 2>/dev/null; then + pass_test "Real-time sync integrated in users controller" +else + fail_test "Real-time sync missing from users controller" +fi + +# Check admin panel integration +if grep -q "listmonk" app/public/admin.html 2>/dev/null; then + pass_test "Admin panel includes Listmonk section" +else + fail_test "Admin panel missing Listmonk integration" +fi + +# Check status indicator integration +if grep -q "listmonk-status" app/public/index.html 2>/dev/null; then + pass_test "Status indicator integrated in main page" +else + fail_test "Status indicator not integrated in main page" +fi + +# Test 5: API Endpoints (if services are running) +echo +echo "๐Ÿ”Œ Test 5: API Endpoints" +echo "-----------------------" + +# Try to test connection endpoint (requires authentication) +STATUS_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$MAP_URL/api/listmonk/status" 2>/dev/null) + +if [[ "$STATUS_RESPONSE" == "401" ]]; then + pass_test "Listmonk status endpoint exists (requires auth)" +elif [[ "$STATUS_RESPONSE" == "200" ]]; then + pass_test "Listmonk status endpoint accessible" +else + warn_test "Listmonk status endpoint response: $STATUS_RESPONSE (may require auth)" +fi + +# Test 6: Docker Configuration +echo +echo "๐Ÿณ Test 6: Docker Configuration" +echo "------------------------------" + +# Check if services are running +if docker-compose ps | grep -q "map-viewer.*Up"; then + pass_test "Map application container is running" +else + fail_test "Map application container not running" +fi + +if docker-compose ps | grep -q "listmonk.*Up"; then + pass_test "Listmonk container is running" +else + fail_test "Listmonk container not running" +fi + +# Test 7: Documentation +echo +echo "๐Ÿ“š Test 7: Documentation" +echo "-----------------------" + +if grep -q "Listmonk Integration" README.md 2>/dev/null; then + pass_test "README includes Listmonk integration documentation" +else + fail_test "README missing Listmonk integration documentation" +fi + +if grep -q "files-explainer.md" app/ 2>/dev/null && grep -q "listmonk" files-explainer.md 2>/dev/null; then + pass_test "Files explainer includes Listmonk files" +else + warn_test "Files explainer may not include Listmonk files" +fi + +# Test 8: JavaScript Module Tests +echo +echo "๐ŸŸจ Test 8: JavaScript Modules" +echo "----------------------------" + +# Check for syntax errors in JavaScript files +JS_FILES=( + "app/public/js/listmonk-status.js" + "app/public/js/listmonk-admin.js" +) + +for js_file in "${JS_FILES[@]}"; do + if [[ -f "$js_file" ]]; then + # Basic syntax check (requires node) + if command -v node > /dev/null; then + if node -c "$js_file" 2>/dev/null; then + pass_test "JavaScript syntax valid: $js_file" + else + fail_test "JavaScript syntax error: $js_file" + fi + else + info_test "Node.js not available for syntax checking: $js_file" + fi + fi +done + +# Test 9: CSS Validation +echo +echo "๐ŸŽจ Test 9: CSS Validation" +echo "------------------------" + +if [[ -f "app/public/css/modules/listmonk.css" ]]; then + # Check for basic CSS structure + if grep -q "@media" app/public/css/modules/listmonk.css; then + pass_test "CSS includes responsive design rules" + else + warn_test "CSS may not include responsive design" + fi + + if grep -q "\.listmonk-" app/public/css/modules/listmonk.css; then + pass_test "CSS includes Listmonk-specific classes" + else + fail_test "CSS missing Listmonk-specific styling" + fi +fi + +# Test 10: Log Analysis (if container is running) +echo +echo "๐Ÿ“‹ Test 10: Application Logs" +echo "---------------------------" + +if docker-compose ps | grep -q "map-viewer.*Up"; then + # Check recent logs for Listmonk-related messages + RECENT_LOGS=$(docker-compose logs --tail=20 map-viewer 2>/dev/null | grep -i listmonk | head -5) + + if [[ -n "$RECENT_LOGS" ]]; then + pass_test "Application logs contain Listmonk activity" + info_test "Recent Listmonk log entries:" + echo "$RECENT_LOGS" | sed 's/^/ /' + else + warn_test "No recent Listmonk activity in logs (may be normal)" + fi + + # Check for error messages + ERROR_LOGS=$(docker-compose logs --tail=50 map-viewer 2>/dev/null | grep -i "error\|fail" | grep -i listmonk | head -3) + + if [[ -n "$ERROR_LOGS" ]]; then + fail_test "Found Listmonk-related errors in logs:" + echo "$ERROR_LOGS" | sed 's/^/ /' + else + pass_test "No Listmonk-related errors in recent logs" + fi +else + warn_test "Cannot analyze logs - Map application container not running" +fi + +# Summary +echo +echo "๐Ÿ“Š Test Summary" +echo "==============" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" +echo -e "Total Tests: $((TESTS_PASSED + TESTS_FAILED))" + +if [[ $TESTS_FAILED -eq 0 ]]; then + echo -e "${GREEN}๐ŸŽ‰ All tests passed! Listmonk integration appears to be working correctly.${NC}" + exit 0 +elif [[ $TESTS_FAILED -le 3 ]]; then + echo -e "${YELLOW}โš ๏ธ Most tests passed, but some issues were found. Check the failures above.${NC}" + exit 1 +else + echo -e "${RED}โŒ Multiple test failures detected. Please review the integration setup.${NC}" + exit 2 +fi