scheduling features

This commit is contained in:
2026-03-01 15:22:27 -07:00
parent aaba7df97d
commit e95bc8883e
40 changed files with 4267 additions and 220 deletions

View File

@@ -0,0 +1,189 @@
/**
* Scheduling Poll Block Hydration for MkDocs
*
* Scans for .scheduling-poll-block elements, fetches poll data from the API,
* and renders a read-only poll summary with a "Vote Now" link to the full
* interactive voting page on the app.
*
* Follows the gancio-events.js hydration pattern.
*/
(function () {
'use strict';
function getApiUrl() {
// env-config.js injects these globals
if (window.PAYMENT_API_URL) return window.PAYMENT_API_URL;
if (window.API_URL) return window.API_URL;
var host = window.location.hostname;
if (host !== 'localhost' && host.indexOf('.') !== -1) {
var parts = host.split('.');
var base = parts.slice(-2).join('.');
return window.location.protocol + '//api.' + base;
}
return 'http://localhost:4000';
}
function getAppUrl() {
if (window.APP_URL) return window.APP_URL;
var host = window.location.hostname;
if (host !== 'localhost' && host.indexOf('.') !== -1) {
var parts = host.split('.');
var base = parts.slice(-2).join('.');
return window.location.protocol + '//app.' + base;
}
return 'http://localhost:3000';
}
var STATUS_COLORS = {
OPEN: '#52c41a',
CLOSED: '#fa8c16',
FINALIZED: '#1890ff',
CANCELLED: '#ff4d4f',
};
var STATUS_LABELS = {
OPEN: 'Open for Voting',
CLOSED: 'Closed',
FINALIZED: 'Date Confirmed',
CANCELLED: 'Cancelled',
};
function formatDate(dateStr) {
var d = new Date(dateStr + 'T00:00:00');
var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return days[d.getDay()] + ', ' + months[d.getMonth()] + ' ' + d.getDate();
}
function hydrateBlocks() {
var blocks = document.querySelectorAll('.scheduling-poll-block');
if (blocks.length === 0) return;
var apiUrl = getApiUrl();
var appUrl = getAppUrl();
blocks.forEach(function (block) {
// Skip if already hydrated
if (block.getAttribute('data-hydrated') === 'true') return;
var slug = block.getAttribute('data-poll-slug');
if (!slug) return;
var showComments = block.getAttribute('data-show-comments') !== 'false';
var title = block.getAttribute('data-title') || '';
block.setAttribute('data-hydrated', 'true');
block.innerHTML = '<div style="text-align:center; padding:20px; opacity:0.6;">Loading poll...</div>';
fetch(apiUrl + '/api/meeting-planner/public/' + encodeURIComponent(slug))
.then(function (res) {
if (!res.ok) throw new Error('Poll not found');
return res.json();
})
.then(function (poll) {
var statusColor = STATUS_COLORS[poll.status] || '#666';
var statusLabel = STATUS_LABELS[poll.status] || poll.status;
var isFinalized = poll.status === 'FINALIZED';
var options = poll.options || [];
var bestScore = 0;
options.forEach(function (o) {
if ((o.score || 0) > bestScore) bestScore = o.score || 0;
});
var html = '';
// Title
if (title) {
html += '<h2 style="text-align:center; margin:0 0 8px; font-size:1.5rem;">' + title + '</h2>';
}
// Poll title + status
html += '<h3 style="margin:0 0 8px; font-size:1.2rem;">' + poll.title + '</h3>';
if (poll.description) {
html += '<p style="opacity:0.75; margin:0 0 8px; line-height:1.5;">' + poll.description + '</p>';
}
html += '<div style="margin-bottom:12px;">';
html += '<span style="display:inline-block; padding:2px 10px; border-radius:4px; font-size:12px; font-weight:600; background:' + statusColor + '22; color:' + statusColor + '; border:1px solid ' + statusColor + '44;">' + statusLabel + '</span>';
if (poll.location) {
html += ' <span style="font-size:13px; opacity:0.65; margin-left:8px;">' + poll.location + '</span>';
}
html += '</div>';
// Finalized banner
if (isFinalized && poll.finalizedOption) {
html += '<div style="padding:10px 14px; border-radius:6px; background:rgba(82,196,26,0.1); border:1px solid rgba(82,196,26,0.3); margin-bottom:12px; color:#52c41a;">';
html += '<strong>Confirmed:</strong> ' + formatDate(poll.finalizedOption.date) + ' &mdash; ' + poll.finalizedOption.startTime + '&ndash;' + poll.finalizedOption.endTime;
html += '</div>';
}
// Options table
if (options.length > 0) {
html += '<div style="overflow-x:auto; margin-bottom:12px;">';
html += '<table style="width:100%; border-collapse:collapse; font-size:13px;">';
html += '<thead><tr>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:left;">Date / Time</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Yes</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">If Need Be</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">No</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Score</th>';
html += '</tr></thead><tbody>';
options.forEach(function (opt) {
var isBest = bestScore > 0 && (opt.score || 0) === bestScore;
var isConfirmed = isFinalized && poll.finalizedOptionId === opt.id;
var rowBg = isConfirmed ? 'rgba(82,196,26,0.08)' : isBest ? 'rgba(82,196,26,0.04)' : '';
html += '<tr style="background:' + rowBg + ';">';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1);">';
html += '<strong>' + formatDate(opt.date) + '</strong><br><span style="font-size:11px; opacity:0.7;">' + opt.startTime + '&ndash;' + opt.endTime + '</span>';
if (isConfirmed) html += ' <span style="color:#52c41a; font-size:10px; font-weight:600;">&#10003;</span>';
html += '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#52c41a;">' + (opt.yesCount || 0) + '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#faad14;">' + (opt.ifNeedBeCount || 0) + '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#d9d9d9;">' + (opt.noCount || 0) + '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; font-weight:600;">' + (opt.score || 0) + '</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
}
// Comments count
if (showComments && poll.comments && poll.comments.length > 0) {
html += '<p style="font-size:13px; opacity:0.65;">' + poll.comments.length + ' comment' + (poll.comments.length !== 1 ? 's' : '') + '</p>';
}
// Vote Now CTA
if (poll.status === 'OPEN') {
html += '<div style="text-align:center; margin-top:16px;">';
html += '<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" ';
html += 'style="display:inline-block; padding:12px 32px; background:#fa8c16; color:#fff; text-decoration:none; border-radius:6px; font-weight:600; font-size:14px;">';
html += 'Vote Now &rarr;</a></div>';
}
block.innerHTML = '<div style="max-width:700px; margin:0 auto;">' + html + '</div>';
})
.catch(function () {
block.innerHTML = '<div style="text-align:center; padding:24px; opacity:0.5;">' +
'<p>Poll unavailable</p>' +
'<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" style="color:#fa8c16;">View poll &rarr;</a>' +
'</div>';
});
});
}
// Initial hydration
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrateBlocks);
} else {
hydrateBlocks();
}
// Re-hydrate on MkDocs SPA navigation
if (typeof document$ !== 'undefined') {
document$.subscribe(function () {
setTimeout(hydrateBlocks, 100);
});
}
})();

View File

@@ -95,10 +95,23 @@ The setup script automatically:
- Saves the API key to `~/.bashrc`
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
- Creates a Termux:Boot auto-start script (if Termux:Boot is installed)
- Starts the SMS server with the watchdog (auto-restarts on crash)
- Starts the SMS server
When done, note the **Phone URL** displayed (e.g. `http://100.64.0.5:5001`).
#### Recommended: Install Service Supervisor
After initial setup, install `termux-services` for reliable process management. This uses runit, a proper UNIX service supervisor that automatically restarts the server if it crashes:
```bash
cd ~/sms-server && bash android/setup-services.sh
```
This registers two supervised services:
- **sms-api** — Flask SMS API server (port 5001)
- **sshd-custom** — SSH daemon for remote management (port 8022)
### Step 4: Prevent Android from Killing Termux
This is **required** for the server to run reliably in the background:
@@ -115,17 +128,35 @@ To pull the latest server code and re-run setup:
cd ~/sms-server && git pull && bash android/setup.sh YOUR_API_KEY_HERE
```
### Manual Control
### Service Management
If you installed `termux-services` (recommended):
```bash
# Check if the server is running
curl http://127.0.0.1:5001/health
# Check status
sv status sms-api
# Restart
sv restart sms-api
# Stop
sv down sms-api
# Start
sv up sms-api
# View logs
tail -f ~/logs/sms-api.log
# Stop the server
pkill -f sms-watchdog.sh && pkill -f termux-sms-api-server.py
# Health check
curl http://127.0.0.1:5001/health
```
Without `termux-services` (legacy watchdog):
```bash
# Check if the server is running
curl http://127.0.0.1:5001/health
# Restart manually
cd ~/sms-server/android && bash sms-watchdog.sh
@@ -353,8 +384,8 @@ export SMS_API_SECRET='correct-key-from-admin-panel'
echo 'export SMS_API_SECRET="correct-key-from-admin-panel"' >> ~/.bashrc
# Restart the server
pkill -f termux-sms-api-server.py
cd ~/sms-server/android && python termux-sms-api-server.py
sv restart sms-api
# Or without termux-services: pkill -f termux-sms-api-server.py && cd ~/sms-server/android && python termux-sms-api-server.py
```
### SMS not sending
@@ -372,12 +403,13 @@ cd ~/sms-server/android && python termux-sms-api-server.py
**Symptoms:** Server stops after some time, especially when phone screen is off.
**Fix:** Disable battery optimization for Termux:
**Fix:**
1. Android Settings → Apps → Termux → Battery → **Unrestricted**
2. Lock Termux in recent apps (long-press app card → Lock)
3. Some phones: Settings → Battery → Battery Optimization → find Termux → Don't Optimize
4. Samsung: Settings → Device Care → Battery → App Power Management → add Termux to "Never sleeping apps"
1. **Install `termux-services`** (if not already): `bash ~/sms-server/android/setup-services.sh` — this uses runit, a proper service supervisor that auto-restarts the server immediately if it crashes
2. **Disable battery optimization:** Android Settings → Apps → Termux → Battery → **Unrestricted**
3. **Lock Termux in recent apps** — long-press the app card → Lock/Pin
4. Samsung: also add Termux, Termux:API, and Termux:Boot to **Settings → Device Care → Battery → Never Sleeping Apps**
5. **Acquire wake lock:** Run `termux-wake-lock` in Termux (included in boot script)
### Server won't start — "Missing SMS_API_SECRET"
@@ -425,6 +457,6 @@ cd ~/sms-server
git pull
# Restart the server
pkill -f termux-sms-api-server.py
cd android && python termux-sms-api-server.py
sv restart sms-api
# Or without termux-services: pkill -f termux-sms-api-server.py && cd android && python termux-sms-api-server.py
```

View File

@@ -183,6 +183,7 @@ Listmonk handles newsletter/marketing campaigns. Sync with the main platform is
| `LISTMONK_ADMIN_USER` | `v2-api` | Same as `LISTMONK_API_USER` (used by the sync service). |
| `LISTMONK_ADMIN_PASSWORD` | &mdash; | Same as `LISTMONK_API_TOKEN`. |
| `LISTMONK_SYNC_ENABLED` | `false` | :material-flask: Set to `true` to sync participants/locations/users to Listmonk lists. |
| `LISTMONK_WEBHOOK_SECRET` | *(empty)* | Shared secret for Listmonk webhook callbacks. |
| `LISTMONK_PROXY_PORT` | `9002` | Nginx proxy port for Listmonk. |
??? example "Listmonk SMTP settings"
@@ -253,6 +254,7 @@ Self-hosted Git repository. Optional service.
| Variable | Default | Description |
|----------|---------|-------------|
| `GITEA_URL` | `http://gitea-changemaker:3000` | Internal container URL for Gitea. |
| `GITEA_PORT` / `GITEA_WEB_PORT` | `3030` | Gitea web UI port. |
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
| `GITEA_DB_TYPE` | `mysql` | Database type (Gitea uses its own MySQL). |
@@ -264,6 +266,18 @@ Self-hosted Git repository. Optional service.
| `GITEA_ROOT_URL` | `https://git.cmlite.org` | Public-facing URL for Gitea. |
| `GITEA_DOMAIN` | `git.cmlite.org` | Domain used in git clone URLs. |
??? example "Gitea Docs Comments"
Enable comments on MkDocs documentation pages, backed by Gitea Issues.
| Variable | Default | Description |
|----------|---------|-------------|
| `GITEA_COMMENTS_ENABLED` | `false` | :material-flask: Enable comments on MkDocs pages. |
| `GITEA_API_TOKEN` | *(empty)* | Personal access token with repo write scope. Create in Gitea &rarr; Settings &rarr; Applications. |
| `GITEA_COMMENTS_REPO_OWNER` | *(empty)* | Gitea username that owns the docs-comments repo. |
| `GITEA_COMMENTS_REPO_NAME` | `docs-comments` | Repository name (auto-created via admin setup). |
| `GITEA_OAUTH_CLIENT_ID` | *(empty)* | OAuth2 application client ID (create in Gitea &rarr; Settings &rarr; Applications &rarr; OAuth2). |
| `GITEA_OAUTH_CLIENT_SECRET` | *(empty)* | OAuth2 application client secret. |
---
## n8n (Workflow Automation) :material-tune-variant:
@@ -382,6 +396,48 @@ Self-hosted event management platform. Uses the shared PostgreSQL database (auto
---
## Jitsi Meet (Video Conferencing) :material-flask:
Self-hosted video conferencing with JWT authentication. Integrates with Rocket.Chat for in-channel video calls.
| Variable | Default | Description |
|----------|---------|-------------|
| `ENABLE_MEET` | `false` | :material-flask: Set to `true` to enable the Jitsi Meet integration. The initial default; once saved in admin Settings, the DB value is authoritative. |
| `JITSI_APP_ID` | `changemaker` | JWT application ID. Must match across Jitsi Prosody, Rocket.Chat app settings, and `JWT_ACCEPTED_ISSUERS`/`JWT_ACCEPTED_AUDIENCES`. |
| `JITSI_APP_SECRET` | &mdash; | :material-alert-circle:{ .text-red } JWT secret for signing Jitsi tokens. Generate with `openssl rand -hex 32`. Shared between Jitsi Prosody, Rocket.Chat, and the API. |
| `JITSI_JICOFO_AUTH_PASSWORD` | &mdash; | Internal XMPP password for Jicofo (conference focus). Generate with `openssl rand -hex 16`. |
| `JITSI_JVB_AUTH_PASSWORD` | &mdash; | Internal XMPP password for JVB (video bridge). Generate with `openssl rand -hex 16`. |
| `JITSI_EMBED_PORT` | `8893` | Port for iframe embedding in admin. |
| `JITSI_URL` | `http://jitsi-web-changemaker:80` | Internal container URL. |
| `JVB_ADVERTISE_IP` | *(empty)* | Server's public IP address. **Required in production** for NAT traversal so remote participants can connect. |
| `JVB_PORT` | `10000` | UDP port for media traffic. Must be open in your firewall. |
!!! warning "Production requirements"
- `JVB_ADVERTISE_IP` must be set to your server's public IP for calls to work outside the local network.
- Port `10000/udp` must be open in your firewall for media traffic.
- Calls must go through the production domain (not localhost) for SSL/JWT to work.
---
## SMS Campaigns (Termux Android Bridge) :material-flask:
Send SMS messages via an Android phone running the Termux API server. The phone acts as an SMS gateway.
| Variable | Default | Description |
|----------|---------|-------------|
| `ENABLE_SMS` | `false` | :material-flask: Set to `true` to enable SMS campaigns. The initial default; once saved in admin Settings, the DB value is authoritative. |
| `TERMUX_API_URL` | `http://10.0.0.193:5001` | URL of the Termux API server running on the Android phone. |
| `TERMUX_API_KEY` | *(empty)* | API key for authenticating with the Termux server (HMAC auth via `X-API-Key` header). |
| `SMS_DELAY_BETWEEN_MS` | `3000` | Delay between sending individual SMS messages (ms). Prevents carrier throttling. |
| `SMS_MAX_RETRIES` | `3` | Maximum retry attempts for failed SMS sends. |
| `SMS_RESPONSE_SYNC_INTERVAL_MS` | `30000` | How often to poll the phone's inbox for responses (ms). |
| `SMS_DEVICE_MONITOR_INTERVAL_MS` | `30000` | How often to check device health &mdash; battery, connectivity (ms). |
!!! tip "GUI configuration"
The Termux API URL and API key can also be configured from **Admin &rarr; Settings &rarr; SMS**. Database values override these env vars when set.
---
## MailHog (Development Email)
| Variable | Default | Description |
@@ -474,6 +530,20 @@ docker compose --profile monitoring up -d
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
| `GOTIFY_ADMIN_PASSWORD` | `admin` | :material-tune-variant: Change in production. |
| `GRAFANA_EMBED_PORT` | `8894` | Port for iframe embedding Grafana in admin. |
| `ALERTMANAGER_EMBED_PORT` | `8895` | Port for iframe embedding Alertmanager in admin. |
---
## Bunker Ops (Fleet Management) :material-flask:
Remote metrics push for managing multiple Changemaker Lite instances from a central monitoring server.
| Variable | Default | Description |
|----------|---------|-------------|
| `INSTANCE_LABEL` | *(empty)* | Unique label for this instance (used as a Prometheus metric label). Falls back to `DOMAIN` if empty. |
| `BUNKER_OPS_ENABLED` | `false` | :material-flask: Enable remote metrics push to a central VictoriaMetrics server. |
| `BUNKER_OPS_REMOTE_WRITE_URL` | *(empty)* | VictoriaMetrics `remote_write` endpoint (e.g., `https://ops.example.com/api/v1/write`). |
---
@@ -516,6 +586,11 @@ echo "ROCKETCHAT_ADMIN_PASSWORD=$(openssl rand -hex 16)"
# Gancio
echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
# Jitsi Meet
echo "JITSI_APP_SECRET=$(openssl rand -hex 32)"
echo "JITSI_JICOFO_AUTH_PASSWORD=$(openssl rand -hex 16)"
echo "JITSI_JVB_AUTH_PASSWORD=$(openssl rand -hex 16)"
```
!!! tip
@@ -551,6 +626,8 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
ENABLE_MEDIA_FEATURES=true
ENABLE_PAYMENTS=true
ENABLE_CHAT=true
ENABLE_MEET=true
ENABLE_SMS=true
LISTMONK_SYNC_ENABLED=true
GANCIO_SYNC_ENABLED=true
LISTMONK_DB_PASSWORD=...
@@ -564,6 +641,10 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
VAULTWARDEN_ADMIN_TOKEN=...
ROCKETCHAT_ADMIN_PASSWORD=...
GANCIO_ADMIN_PASSWORD=...
JITSI_APP_SECRET=...
JITSI_JICOFO_AUTH_PASSWORD=...
JITSI_JVB_AUTH_PASSWORD=...
JVB_ADVERTISE_IP=your.public.ip.here
EMAIL_TEST_MODE=false
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587

View File

@@ -87,6 +87,7 @@ extra_javascript:
- assets/js/image-gallery.js
- assets/js/gancio-events.js
- assets/js/payment-widgets.js
- assets/js/scheduling-poll.js
- javascripts/ad-widgets.js
- javascripts/docs-comments.js