A grant lottery system for distributing monthly grants to people in need. Applications are collected during a limited window, then winners are randomly drawn at month end.
- People apply via SMS, email, or web form during a monthly window
- Identity is resolved against known applicants by phone number
- Eligibility is checked (3-month cooldown, no duplicate applications)
- Eligible applicants enter the lottery pool
- At month end, winners are drawn based on available funds
- Winners receive grants via bank transfer or cash
Event-sourced using Emmett with SQLite.
Form data
|
v
submitApplication() -- application service (orchestrates I/O)
|
|-- resolveIdentity() -- queries known_applicants projection
|-- decide() -- pure decider, no I/O
|-- CommandHandler -- persists events to event store
|
v
Events --> Projections -- inline projections update read models
| File | Role |
|---|---|
submitApplication.ts |
Application service. Single entry point for the submission flow |
decider.ts |
Pure decider. Command + state in, events out |
resolveIdentity.ts |
Queries the known_applicants read model by phone number |
normalizeName.ts |
Strips diacritics, normalizes whitespace and casing for name comparison |
types.ts |
Commands, events, state, and value objects |
| File | Role |
|---|---|
eventStore.ts |
Creates the SQLite event store with inline projections |
projections/eligibility.ts |
Tracks accepted applications per month cycle |
projections/knownApplicants.ts |
Maps phone numbers to applicant IDs and names |
When an application is submitted, the phone number is looked up against known applicants:
- No match -- new applicant, assigned
applicant-{phone}as ID - Phone + name match -- linked to existing applicant
- Phone matches, name differs -- flagged for volunteer review, eligibility deferred
SubmitApplication (command)
|
+--> ApplicationSubmitted (always emitted)
|
+--> ApplicationAccepted (eligible, identity resolved)
| or ApplicationRejected (cooldown / duplicate)
| or ApplicationFlaggedForReview (name mismatch on known phone)
bun install
bun testRequires a VPS with SSH access. Docker and dependencies are installed automatically.
./install.sh root@your-serverSecrets (ADMIN_PASSWORD, ALTCHA_HMAC_KEY) are auto-generated and saved to /var/lib/csf/.env on the server. Save them — they're only printed once.
Push to main — the CI pipeline builds and publishes a new Docker image to GHCR. Then:
./install.sh root@your-serverIt pulls the latest image and restarts the container. Existing secrets and database are preserved.
PORT=443 FUND_NAME="Mutual Aid Fund" ./install.sh root@your-server| Variable | Default | Notes |
|---|---|---|
APP_NAME |
csf |
Container and directory name |
DATA_DIR |
/var/lib/$APP_NAME |
Persistent data (DB, .env) |
PORT |
443 |
Host port (Caddy serves HTTPS) |
FUND_NAME |
Community Solidarity Fund |
Displayed in the UI |
ADMIN_PASSWORD |
auto-generated | Only used on first boot if no admin exists |
ALTCHA_HMAC_KEY |
auto-generated | Required every boot — preserved across upgrades |
/var/lib/csf/csf.db— SQLite database (bind-mounted, survives upgrades)/var/lib/csf/.env— Secrets and config (preserved across upgrades)/var/lib/csf/docker-compose.yml— Compose file (overwritten each deploy)