listmonk sync

This commit is contained in:
admin 2025-08-15 11:14:38 -06:00
parent b885d89ae4
commit c973fe55cd
20 changed files with 3416 additions and 6 deletions

View File

@ -1,6 +1,18 @@
# NocoDB Map Viewer
A- <20> **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
- <20>👨💼 User management panel for admin users (create, delete users)
- <20> **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- <20> **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 <listmonk-container-name> | 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 <network-name>
# 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 <map-container-name> /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

View File

@ -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'
});
}
};

View File

@ -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'

View File

@ -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'

View File

@ -51,6 +51,10 @@
<span class="nav-icon">🗄️</span>
<span class="nav-text">NocoDB Links</span>
</a>
<a href="#listmonk-links">
<span class="nav-icon">📧</span>
<span class="nav-text">Listmonk Links</span>
</a>
<a href="#start-location" class="active">
<span class="nav-icon">📍</span>
<span class="nav-text">Start Location</span>
@ -75,6 +79,10 @@
<span class="nav-icon">📊</span>
<span class="nav-text">Convert Data</span>
</a>
<a href="#listmonk">
<span class="nav-icon">📧</span>
<span class="nav-text">Email Lists</span>
</a>
</nav>
<div class="sidebar-footer">
<div id="mobile-admin-info" class="mobile-admin-info mobile-only"></div>
@ -207,6 +215,86 @@
</div>
</section>
<!-- Listmonk Links Section -->
<section id="listmonk-links" class="admin-section" style="display: none;">
<h2>Listmonk Email Management Links</h2>
<p>Quick access to all Listmonk email management interfaces for campaign communications.</p>
<div class="nocodb-info">
<div class="info-box">
<h4>📧 About Listmonk</h4>
<p>
Listmonk is the email marketing platform that powers campaign communications. Use these links to directly access and manage your email lists, campaigns, and subscribers through the Listmonk interface.
</p>
<p>
The Map application automatically syncs data to Listmonk email lists based on support levels and sign requests.<br>
<a href="https://listmonk.app/docs/" target="_blank" rel="noopener" class="btn btn-link">Listmonk Documentation</a>
<a href="https://github.com/knadh/listmonk" target="_blank" rel="noopener" class="btn btn-link">GitHub Repository</a>
</p>
</div>
</div>
<div class="nocodb-links-container">
<div class="nocodb-cards">
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>🏠 Dashboard</h3>
<span class="nocodb-card-badge">Main</span>
</div>
<p>Main Listmonk dashboard with overview and analytics</p>
<a href="#" id="admin-listmonk-admin-link" class="btn btn-primary" target="_blank">
Open Dashboard
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>📋 Email Lists</h3>
<span class="nocodb-card-badge">Lists</span>
</div>
<p>Manage email lists and subscriber segments</p>
<a href="#" id="admin-listmonk-lists-link" class="btn btn-secondary" target="_blank">
Open Lists
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>📧 Campaigns</h3>
<span class="nocodb-card-badge">Email</span>
</div>
<p>Create and manage email campaigns</p>
<a href="#" id="admin-listmonk-campaigns-link" class="btn btn-secondary" target="_blank">
Open Campaigns
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>👤 Subscribers</h3>
<span class="nocodb-card-badge">People</span>
</div>
<p>View and manage email subscribers</p>
<a href="#" id="admin-listmonk-subscribers-link" class="btn btn-secondary" target="_blank">
Open Subscribers
</a>
</div>
<div class="nocodb-card">
<div class="nocodb-card-header">
<h3>⚙️ Settings</h3>
<span class="nocodb-card-badge">Config</span>
</div>
<p>Configure Listmonk settings and SMTP</p>
<a href="#" id="admin-listmonk-settings-link" class="btn btn-secondary" target="_blank">
Open Settings
</a>
</div>
</div>
</div>
</section>
<!-- Start Location Section -->
<section id="start-location" class="admin-section">
<h2>Map Start Location</h2>
@ -976,6 +1064,113 @@
</div>
</div>
</section>
<!-- Email Lists (Listmonk) Section -->
<section id="listmonk" class="admin-section" style="display: none;">
<h2>Email List Management (Listmonk)</h2>
<p>Manage email list synchronization with Listmonk for campaign communications.</p>
<div class="listmonk-container">
<div class="listmonk-stats">
<h3>Sync Status</h3>
<div id="sync-status-display">
<div class="status-row">
<span>Connection:</span>
<span id="connection-status">Checking...</span>
</div>
<div class="status-row">
<span>Auto-sync:</span>
<span id="autosync-status">Checking...</span>
</div>
<div class="status-row">
<span>Last Error:</span>
<span id="last-error">None</span>
</div>
</div>
</div>
<div class="listmonk-actions">
<h3>Sync Actions</h3>
<p>Force synchronization of all data to Listmonk email lists:</p>
<div class="sync-buttons">
<button id="sync-locations-btn" class="btn btn-primary">
<span>📍</span> Sync All Locations
</button>
<button id="sync-users-btn" class="btn btn-primary">
<span>👤</span> Sync All Users
</button>
<button id="sync-all-btn" class="btn btn-success">
<span>🔄</span> Sync Everything
</button>
<button id="refresh-status-btn" class="btn btn-secondary">
<span>🔍</span> Refresh Status
</button>
<button id="test-connection-btn" class="btn btn-warning">
<span>🔗</span> Test Connection
</button>
<button id="reinitialize-lists-btn" class="btn btn-info">
<span>🛠️</span> Reinitialize Lists
</button>
</div>
</div>
</div>
<div id="sync-progress" class="sync-progress" style="display: none;">
<h3>Sync Progress</h3>
<div class="progress-bar-container">
<div class="progress-bar">
<div class="progress-bar-fill" id="sync-progress-bar"></div>
</div>
</div>
<div id="sync-results"></div>
</div>
<div class="listmonk-info">
<h3>About Email List Sync</h3>
<div class="info-grid">
<div class="info-section">
<h4>Automatic Lists Created:</h4>
<ul>
<li><strong>Map Locations - All:</strong> All contacts with email addresses</li>
<li><strong>Map Users - All:</strong> All system users</li>
<li><strong>Support Level 1-4:</strong> Segmented by support level</li>
<li><strong>Has Campaign Sign:</strong> Contacts with signs</li>
<li><strong>Wants Campaign Sign:</strong> Contacts wanting signs (from notes)</li>
</ul>
</div>
<div class="info-section">
<h4>Real-time Sync:</h4>
<ul>
<li>New locations automatically sync to appropriate lists</li>
<li>Updates are reflected immediately in email lists</li>
<li>Deletions remove contacts from all lists</li>
<li>Sync failures are logged and shown in status</li>
</ul>
</div>
<div class="info-section">
<h4>Contact Data Synced:</h4>
<ul>
<li>Email, name, phone, address</li>
<li>Support level, sign status, notes</li>
<li>Geographic coordinates</li>
<li>Source tracking (map_location/map_user)</li>
</ul>
</div>
<div class="info-section">
<h4>Troubleshooting:</h4>
<ul>
<li>Check Listmonk service is running</li>
<li>Verify credentials in environment variables</li>
<li>Use "Test Connection" to verify setup</li>
<li>Check logs for detailed error information</li>
</ul>
</div>
</div>
</div>
</section>
</div>
</div>
@ -1053,6 +1248,10 @@
<!-- Admin Cuts JavaScript -->
<script src="js/admin-cuts.js"></script>
<!-- Listmonk Status and Admin -->
<script src="js/listmonk-status.js"></script>
<script src="js/listmonk-admin.js"></script>
<!-- Data Convert JavaScript -->
<!-- Admin JavaScript -->
<script src="js/admin.js"></script>

View File

@ -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;
}
}

View File

@ -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");

View File

@ -444,6 +444,9 @@
<!-- Cache Management -->
<script src="js/cache-manager.js"></script>
<!-- Listmonk Status -->
<script src="js/listmonk-status.js"></script>
<!-- Application JavaScript -->
<script type="module" src="js/main.js"></script>
</body>

View File

@ -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 = [];

View File

@ -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 = '<span class="status-connected">✅ Connected</span>';
} else if (status.enabled && !status.connected) {
connectionStatus.innerHTML = '<span class="status-error">❌ Failed</span>';
} else {
connectionStatus.innerHTML = '<span class="status-disabled">⭕ Disabled</span>';
}
console.log('✅ Connection status updated:', connectionStatus.innerHTML);
}
if (autosyncStatus) {
if (status.enabled) {
autosyncStatus.innerHTML = '<span class="status-connected">✅ Enabled</span>';
} else {
autosyncStatus.innerHTML = '<span class="status-disabled">⭕ Disabled</span>';
}
console.log('✅ Auto-sync status updated:', autosyncStatus.innerHTML);
}
if (lastError) {
if (status.lastError) {
lastError.innerHTML = `<span class="status-error">${escapeHtml(status.lastError)}</span>`;
} else {
lastError.innerHTML = '<span class="status-connected">None</span>';
}
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 = '<h4>Email Lists</h4>';
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 = '<p class="status-disabled">No email lists found</p>';
} else {
statsArray.forEach(list => {
const statRow = document.createElement('div');
statRow.className = 'status-row';
statRow.innerHTML = `
<span title="${escapeHtml(list.description || '')}">${escapeHtml(list.name)}</span>
<span class="status-connected">${list.subscriberCount} subscribers</span>
`;
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 = '<div class="sync-result info">Starting sync...</div>';
}
// 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 = `
<div class="sync-result error">
<strong>Sync Failed:</strong> ${escapeHtml(error.message)}
</div>
`;
}
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 += `<div class="sync-result success">
<strong> ${escapeHtml(result.message)}</strong>
</div>`;
// 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 += `<div class="sync-result error">
<strong> Sync Failed:</strong> ${escapeHtml(result.error)}
</div>`;
showNotification('Sync failed', 'error');
}
resultsDiv.innerHTML = html;
}
/**
* Format sync results for display
*/
function formatSyncResults(type, results) {
let html = `<div class="sync-result info">
<strong>${type}:</strong>
${results.success} succeeded, ${results.failed} failed
(${results.total} total)
</div>`;
// Show errors if any
if (results.errors && results.errors.length > 0) {
html += `<div class="sync-result error">
<strong>Errors:</strong>
<ul style="margin: 5px 0 0 20px; font-size: 12px;">`;
// Show only first 5 errors to avoid overwhelming the UI
const errorsToShow = results.errors.slice(0, 5);
errorsToShow.forEach(error => {
html += `<li>${escapeHtml(error.email || error.id)}: ${escapeHtml(error.error)}</li>`;
});
if (results.errors.length > 5) {
html += `<li><em>... and ${results.errors.length - 5} more errors</em></li>`;
}
html += `</ul></div>`;
}
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;

View File

@ -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 = `
<span class="status-icon"></span>
<span class="status-text">Email Sync</span>
`;
// 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 = `
<div class="notification-header">
<strong> Email Sync Error</strong>
<button class="close-btn" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
<div class="notification-body">
<p>The email list synchronization is not working:</p>
<code>${this.escapeHtml(error || 'Connection failed')}</code>
<p>New contacts will be saved locally but won't sync to email lists until this is resolved.</p>
<p>Please contact your administrator if this persists.</p>
</div>
`;
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 = `
<div class="notification-header">
<strong> Email Sync Restored</strong>
<button class="close-btn" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
<div class="notification-body">
<p>Email list synchronization is working again. New contacts will now sync automatically.</p>
</div>
`;
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;

View File

@ -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;

View File

@ -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'));

View File

@ -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;

View File

@ -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);

View File

@ -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();

View File

@ -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.

View File

@ -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.

View File

@ -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

293
map/test-listmonk-integration.sh Executable file
View File

@ -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