Self-hosted territory management system for religious congregations — built on PocketBase and Go.
- ✨ Features
- 🏗️ Architecture
- 🚀 Quick Start
- 🐳 Docker Deployment
- ⚙️ Configuration
- ⏰ Scheduled Jobs
- 🛠️ Development
- 🧪 Testing
- 📡 API Reference
- 🔒 Security
- 📚 Documentation
| 🔐 Auth & Access Control | JWT auth with role-based permissions (administrator / conductor / read_only) |
| 🌍 Territory Management | Hierarchical congregations → territories → maps → addresses |
| 📍 Smart Quicklink | Proximity-based map assignment using Haversine distance + workload balancing |
| 📈 Aggregation Engine | Debounced, semaphore-guarded real-time progress tracking per map and territory |
| 📊 Real-time Updates | Server-Sent Events (SSE) via PocketBase subscriptions for live field sync |
| 📧 Email Digests | Message, instruction, notes, and new-address digests via MailerSend |
| 📑 Monthly Excel Reports | Auto-generated congregation reports with territory grids, DNC lists, and stats |
| 🤖 AI Summaries | Optional OpenAI-powered narrative summaries inside reports and digests |
| 👤 User Lifecycle | Automated inactivity warnings and deprovisioning (NIST SP 800-53 AC-2 aligned) |
| 🔍 Observability | Sentry error tracking with log forwarding and panic recovery on all jobs |
| 🎛️ Feature Flags | All background jobs gated by LaunchDarkly flags — toggle without redeployment |
| 🩺 Health Check | SQLite PRAGMA quick_check endpoint for uptime monitoring |
graph TD
Client["🖥️ Frontend Client\n(React 19 PWA)"]
SDK["PocketBase JS SDK"]
API["🗄️ PocketBase Core\n:8090 — CRUD + SSE + Auth"]
Custom["⚙️ Custom Handlers\n/map/* /territory/* /address/*"]
Hooks["🪝 Domain Hooks\naddresses · users · roles · assignments"]
DB[("💾 SQLite\npb_data/")]
Jobs["⏰ Job Scheduler\n8 cron jobs (LaunchDarkly-gated)"]
Sentry["🔍 Sentry"]
LD["🎛️ LaunchDarkly"]
Email["📧 MailerSend"]
AI["🤖 OpenAI"]
Client --> SDK --> API
API --> Custom
API --> Hooks
API --> DB
Custom --> DB
Hooks --> DB
Jobs --> DB
Jobs --> Email
Jobs --> AI
Jobs --> LD
API --> Sentry
Custom --> Sentry
Hooks --> Sentry
sequenceDiagram
participant C as Client
participant API as PocketBase API
participant Auth as RequireAuth / link-id
participant H as Handler
participant DB as SQLite
C->>API: POST /territory/link (JWT)
API->>Auth: validate token
Auth-->>API: user context
API->>H: HandleTerritoryQuicklink
H->>DB: fetch maps + assignments
H->>H: rank by workload · distance · progress
H->>DB: create assignment
H-->>C: { map_id, distance, assignment_count }
| Tool | Version | Notes |
|---|---|---|
| Go | 1.24+ | brew install go |
| Git | any | — |
# 1. Clone
git clone git@github.com:rimorin/ministry-mapper-be.git
cd ministry-mapper-be
# 2. Install dependencies
./scripts/install.sh
# 3. Configure environment
cp .env.sample .env
# Minimum required: PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, MAILERSEND_API_KEY
# 4. Start development server
./scripts/start.shNote
Server: http://localhost:8090 · Admin UI: http://localhost:8090/\_/
Tip
Without LAUNCHDARKLY_SDK_KEY all feature flags default to enabled, so background jobs run immediately in development.
# Build image
docker build -t ministry-mapper .
# Run container
docker run -d \
--name ministry-mapper \
-p 8080:8080 \
-v /path/to/pb_data:/app/pb_data \
--env-file .env \
ministry-mapperImportant
Always mount /app/pb_data to a persistent volume. This directory holds the SQLite database, user uploads, and PocketBase configuration. Losing it means losing all data.
| Variable | Description | Default | Required |
|---|---|---|---|
PB_APP_URL |
Frontend application URL | http://localhost:3000 |
✅ |
PB_ALLOW_ORIGINS |
CORS origins (comma-separated) | * |
✅ |
PB_APP_NAME |
Application display name | Ministry Mapper |
— |
PB_ADMIN_EMAIL |
Bootstrap superuser email | — | ✅ |
PB_ADMIN_PASSWORD |
Bootstrap superuser password | — | ✅ |
MAILERSEND_API_KEY |
MailerSend API key for all outbound email | — | ✅ |
MAILERSEND_FROM_EMAIL |
Sender email address | — | ✅ |
LAUNCHDARKLY_SDK_KEY |
LaunchDarkly SDK key for feature flags | — | ✅ |
LAUNCHDARKLY_CONTEXT_KEY |
LaunchDarkly environment context identifier | — | ✅ |
SENTRY_DSN |
Sentry DSN for error tracking | — | ✅ |
SENTRY_ENV |
Environment label (development / staging / production) |
development |
✅ |
OPENAI_API_KEY |
OpenAI key for AI-generated summaries in reports/digests | — |
📋 Additional environment variables (SMTP, auth, rate limiting)
| Variable | Description | Default |
|---|---|---|
PB_SMTP_HOST |
SMTP relay host | "" |
PB_SMTP_PORT |
SMTP port | 587 |
PB_SMTP_USERNAME |
SMTP username | "" |
PB_SMTP_PASSWORD |
SMTP password | "" |
PB_SMTP_SENDER_ADDRESS |
Envelope sender address | support@ministry-mapper.com |
PB_SMTP_SENDER_NAME |
Envelope sender name | MM Support |
PB_OTP_ENABLED |
Enable one-time-password auth | false |
PB_MFA_ENABLED |
Enable multi-factor auth | false |
PB_ENABLE_RATE_LIMITING |
Enable API rate limiting | false |
PB_HIDE_CONTROLS |
Hide PocketBase admin controls | false |
| Environment | Port | Notes |
|---|---|---|
| Development | 8090 |
set by ./scripts/start.sh |
| Docker | 8080 |
configurable via -p flag |
All jobs are LaunchDarkly-gated — toggle any job on or off without redeployment. When LaunchDarkly is not configured, all flags default to enabled.
Schedules are staggered so no two jobs fire at the same minute. Heavy, non-urgent jobs run at 02:00–03:00 SGT (18:00–19:00 UTC) — well clear of the peak field-service window (08:00–12:00 SGT).
Note
Map progress (done %, not-home counts) is recalculated in real time via a debounced OnRecordAfterUpdateSuccess hook on addresses — not by a cron job. A 10-second debounce coalesces rapid taps into a single DB write, and a semaphore (max 5) prevents SQLite saturation during peak bursts.
| Job | Cron (UTC) | SGT | Feature Flag | Description |
|---|---|---|---|---|
cleanUpAssignments |
1,6,11,…,56 * * * * |
every 5 min | enable-assignments-cleanup |
Expire and remove stale map assignments |
processMessages |
8,38 * * * * |
every 30 min | enable-message-processing |
Send unread message digest emails |
processInstructions |
18,48 * * * * |
every 30 min | enable-instruction-processing |
Send territory instruction digest emails |
processNotes |
28 * * * * |
every hour | enable-note-processing |
Send updated address notes digest |
generateMonthlyReport |
0 18 1 * * |
02:00 SGT, 1st | enable-monthly-report |
Build & email Excel report to all admins |
processUnprovisionedUsers |
0 18 * * * |
02:00 SGT daily | enable-unprovisioned-user-processing |
Warn then disable users with no role |
processInactiveUsers |
30 18 * * * |
02:30 SGT daily | enable-inactive-user-processing |
Warn then disable inactive accounts |
processNewAddresses |
0 19 * * * |
03:00 SGT daily | enable-new-addresses-notification |
Digest of app-created addresses (last 24 h) |
| Script | Purpose |
|---|---|
./scripts/install.sh |
Install Go module dependencies |
./scripts/start.sh |
Start development server on :8090 |
./scripts/update.sh |
Update all Go dependencies |
./scripts/test.sh |
Bootstrap test DB and run integration tests |
ministry-mapper-be/
├── main.go # Entrypoint: wires Sentry, hooks, routes, scheduler
├── internal/
│ ├── handlers/ # Custom HTTP endpoint handlers
│ │ ├── common.go # Shared DB helpers & auth utilities
│ │ ├── get_quicklink.go # POST /territory/link — proximity assignment
│ │ ├── update_aggregates.go # Map & territory progress recalculation
│ │ ├── generate_report.go # POST /report/generate — on-demand reports
│ │ └── ... # Map, territory, address, options handlers
│ ├── jobs/ # Background job implementations
│ │ ├── job_scheduler.go # Cron setup + LaunchDarkly flag wiring
│ │ ├── generate_report.go # Monthly Excel report builder (MailerSend + OpenAI)
│ │ ├── llm_client.go # OpenAI client wrapper
│ │ ├── summary_data.go # Report analytics & LLM prompt builder
│ │ └── process_*.go # Individual job implementations
│ ├── middleware/ # Sentry error middleware & job panic recovery
│ └── setup/
│ ├── routes.go # Route registration & CORS
│ └── hooks.go # PocketBase record hooks (addresses, users, roles)
├── migrations/ # DB migrations (seed file is testdata-only)
├── templates/ # Go HTML email templates
├── scripts/ # Dev & CI helper scripts
├── test_pb_data/ # Generated test DB (gitignored)
└── pb_data/ # PocketBase runtime data (gitignored)
Integration tests require a local test database generated by scripts/test.sh. The DB is not committed to git — it is created on demand to prevent secrets from leaking via migration env vars.
./scripts/test.shThis script:
- Wipes any existing
test_pb_data/ - Bootstraps a fresh DB with seed data (
go run -tags testdata) - Checkpoints WAL files and verifies row counts
- Runs all integration tests (
go test ./...)
All seeded IDs are stable and safe to hard-code in tests:
📋 Expand seed data table
| Resource | IDs |
|---|---|
| Congregations | testcongalpha01, testcongbeta001 |
| Territories | testterralpha01, testterralpha02, testterrbeta001 |
| Maps | testmapalpha01a, testmapalpha01b, testmapalpha02a, testmapalpha02b, testmapbeta001a, testmapbeta001b |
Users (password: Test1234!) |
admin@alpha.test (administrator), conductor@alpha.test (conductor), readonly@alpha.test (read_only), admin@beta.test, xcong@beta.test |
To add or change seeded records, edit migrations/1780000000_seed_test_data.go, then re-run ./scripts/test.sh.
Note
The script unsets all production env vars before bootstrapping so migration fallback defaults are always used — no real credentials can leak into the generated DB.
Use the PocketBase JavaScript SDK for standard CRUD, real-time subscriptions, and authentication.
import PocketBase from "pocketbase";
const pb = new PocketBase("http://localhost:8090");
// Authenticate
await pb.collection("users").authWithPassword("user@example.com", "password");
// Real-time subscription (SSE)
pb.collection("addresses").subscribe("*", (e) => {
console.log(e.action, e.record);
});Note
Routes marked JWT or link-id accept either a Authorization: Bearer <token> header or a link-id header for publisher (unauthenticated) access. All other custom routes require a valid JWT.
| Endpoint | Auth | Description |
|---|---|---|
GET /api/db-health |
None | SQLite PRAGMA quick_check health probe |
POST /link/map |
link-id |
Resolve a share link to its map and territory |
POST /map/addresses |
JWT or link-id |
Get all addresses and options for a map |
POST /address/update |
JWT or link-id |
Update an address status or notes |
POST /address/add |
JWT or link-id |
Create a new address on a map |
| Endpoint | Role | Description |
|---|---|---|
POST /map/codes |
Administrator | List distinct address codes for a map |
POST /map/code/add |
Administrator | Add one or more address codes |
POST /map/code/delete |
Administrator | Delete an address code |
POST /map/codes/update |
Administrator | Reorder address codes within a map |
POST /map/floor/add |
Administrator | Add a floor to a multi-level map |
POST /map/floor/remove |
Administrator | Remove a floor (refuses to remove last floor) |
POST /map/reset |
Administrator | Reset all addresses in a map to not_done |
POST /map/add |
Administrator | Create a new map with initial addresses |
POST /map/territory/update |
Administrator | Move a map to a different territory |
POST /maps/sequence |
Administrator | Reorder maps within a territory |
POST /options/update |
Administrator | Batch create / update / delete address options |
POST /report/generate |
Administrator | Trigger an on-demand congregation report |
| Endpoint | Role | Description |
|---|---|---|
POST /territory/reset |
Administrator or Conductor | Reset all maps in a territory |
POST /territory/delete |
Administrator or Conductor | Delete a territory and all its maps |
| Endpoint | Role | Description |
|---|---|---|
POST /territory/link |
Any | Smart map assignment (Quicklink) |
📬 Quicklink algorithm details
POST /territory/link ranks available maps by three criteria in priority order:
- Workload — maps with fewer active assignments are preferred
- Proximity — Haversine distance from the user's coordinates (50 m threshold)
- Progress — maps with lower completion % are preferred; 100% complete maps are skipped
On a successful match an assignment record is created with an expiry derived from the congregation's expiry_hours setting.
Request body:
{ "territory": "<territory_id>", "coordinates": { "lat": 1.23, "lng": 103.45 } }Response:
{ "map_id": "<map_id>", "distance": 35.7, "assignment_count": 1 }Warning
Never commit .env files, API keys, or admin credentials to version control.
- ✅ Always use HTTPS in production
- ✅ Store all secrets as environment variables (never in source code)
- ✅ Rotate credentials regularly and keep dependencies updated (
./scripts/update.sh) - ✅ The test seed script strips all production env vars before bootstrapping — safe to run in CI
| Resource | Description |
|---|---|
| Official Docs | Complete user and developer guides |
| Frontend Repo | Ministry Mapper v2 — React 19 + TypeScript PWA |
| PocketBase Docs | PocketBase platform documentation |
| Go API Reference | Go package reference |
Built with PocketBase — an open-source BaaS platform with a built-in admin dashboard, real-time subscriptions, SQLite, file storage, and user authentication.