How the Hebrew Calendar ad platform fits together — from iOS/Android apps requesting ads, through the Node server and SQLite database, to backups in DigitalOcean Spaces.
Three actors: the mobile apps (where ads are displayed), the ad server (decisions + state), and DigitalOcean (where everything is hosted). The customer dashboards and admin panel are just web views talking to the same server.
Key insight: the whole system is intentionally small. One server, one database, one storage bucket. No microservices, no queue, no Redis. This is right-sized for the current load (~157K MAU, <100 ad requests/sec peak) and lets one developer maintain it.
When the iOS or Android app needs to show an interstitial, the SDK calls GET /v1/ad/next with the user's country, platform, language, and device ID. The server:
is_active=1, date range matches today, max impressions not exceeded, targeting matches the requestis_solo=1, narrows the pool to just solos (Solo Lock)Cache-Control: no-store to prevent iOS URLCache from returning stale adsTypical response time: 20-40ms. Most of the latency is the SQL query plus network round-trip.
When the user sees, taps, or skips an ad, the SDK fires POST /v1/event/{impression|click|close} with the campaign_id, device_id, user country, and platform. The server:
impressions, clicks, or closes tablecurrent_impressions counter so cap enforcement works{ok: true} with status 204
Customer opens https://ads.hebcal.co.il/r/dashboard/<token>. The server:
campaign_access_tokens table (must have is_active=1)campaign_iddashboard.html with the campaign_id embedded/r/dashboard/<token>/data for live numbers (refreshes every 60s)10 tables. SQLite WAL mode for concurrent reads during writes. Foreign keys enforced. Backed up to Spaces every hour and after every write (debounced).
The companies that run campaigns. Currently: 3 paying customers (Sirocco, Graphoholic) + in-house promo campaigns.
id INTEGER PRIMARY KEY AUTOINCREMENT name TEXT NOT NULL email TEXT notes TEXT created_at TEXT DEFAULT CURRENT_TIMESTAMP
Each ad campaign with targeting, weighting, Solo Lock, pricing model, and A/B test grouping.
id INTEGER PRIMARY KEY AUTOINCREMENT advertiser_id INTEGER NOT NULL REFERENCES advertisers(id) name TEXT NOT NULL image_url TEXT NOT NULL click_url TEXT NOT NULL target_countries TEXT -- JSON array: ["IL", "US"] target_platforms TEXT -- JSON array: ["ios", "android"] target_languages TEXT -- JSON array: ["he", "en"] start_date TEXT -- YYYY-MM-DD end_date TEXT -- YYYY-MM-DD max_impressions INTEGER -- 0 = unlimited current_impressions INTEGER DEFAULT 0 skip_after_seconds INTEGER DEFAULT 3 duration_seconds INTEGER DEFAULT 5 weight INTEGER DEFAULT 1 -- Rotation share is_solo INTEGER DEFAULT 0 -- Solo Lock flag pricing_model TEXT DEFAULT 'flat' -- 'flat' | 'cpm' | 'cpc' rate_agorot INTEGER DEFAULT 0 -- CPM per 1000 imp / CPC per click budget_total_agorot INTEGER DEFAULT 0 -- 0 = unlimited budget_spent_agorot INTEGER DEFAULT 0 -- Auto-paused when ≥ total ab_test_id TEXT -- Groups A+B variants ab_variant TEXT -- 'A' | 'B' | NULL is_active INTEGER DEFAULT 0 created_at TEXT DEFAULT CURRENT_TIMESTAMP
Every ad shown. The fact table for all analytics. Country + city derived from IP via geoip-lite.
id INTEGER PRIMARY KEY AUTOINCREMENT campaign_id INTEGER NOT NULL REFERENCES campaigns(id) device_id TEXT country TEXT -- ISO 3166 alpha-2 (IP-derived) city TEXT -- IP-derived city name platform TEXT -- 'ios' or 'android' language TEXT created_at TEXT DEFAULT CURRENT_TIMESTAMP
Every ad click. Joined to impressions for CTR.
id INTEGER PRIMARY KEY AUTOINCREMENT campaign_id INTEGER NOT NULL REFERENCES campaigns(id) device_id TEXT country TEXT -- IP-derived city TEXT -- IP-derived platform TEXT created_at TEXT DEFAULT CURRENT_TIMESTAMP
Inbound prospect inquiries from advertise.html / pricing.html. Has soft-delete + spam-flagging fields.
id INTEGER PRIMARY KEY AUTOINCREMENT name TEXT email TEXT phone TEXT company TEXT message TEXT source TEXT -- 'pricing' | 'advertise' | 'whatsapp' campaign_id INTEGER REFERENCES campaigns(id) landing_page TEXT is_valid INTEGER DEFAULT 1 -- spam flag invalid_reason TEXT is_deleted INTEGER DEFAULT 0 created_at TEXT DEFAULT CURRENT_TIMESTAMP
When a user taps a WhatsApp CTA on the landing pages. Separate from ad clicks.
id INTEGER PRIMARY KEY AUTOINCREMENT landing_page TEXT position TEXT -- which button on the page (hero, footer, etc.) device_id TEXT created_at TEXT DEFAULT CURRENT_TIMESTAMP
Per-campaign access tokens for customer dashboards. The token IS the credential.
id INTEGER PRIMARY KEY AUTOINCREMENT campaign_id INTEGER NOT NULL REFERENCES campaigns(id) token TEXT NOT NULL UNIQUE -- e.g., 'isr-7f3a9b2c' label TEXT -- internal identifier is_active INTEGER DEFAULT 1 created_at TEXT DEFAULT CURRENT_TIMESTAMP last_used TEXT view_count INTEGER DEFAULT 0
Every route the server exposes. Three audiences: apps (no auth, public), customers (token-based), admin (header auth).
Local repo: ~/hebrew-calendar-ad-server. Single main branch. Push to Bitbucket → DigitalOcean auto-deploys (~2 min).
hebrew-calendar-ad-server/ ├── src/ │ ├── db/ │ │ └── index.js # Schema, migrations, backup/restore │ ├── index.js # Express setup, route registration │ ├── routes/ │ │ ├── ads.js # /v1/ad/next + event endpoints │ │ ├── admin.js # All /v1/admin/* routes │ │ ├── dashboard.js # Public customer dashboards /r/* │ │ ├── events.js # whatsapp_click, lead form │ │ ├── reports.js # JSON + CSV reports │ │ └── upload.js # Image upload to Spaces │ └── lib/ │ ├── spaces.js # DO Spaces SDK wrapper │ └── notify.js # Telegram notification helpers ├── admin/ │ └── index.html # Single-file admin (~2200 lines) ├── public/ │ ├── pricing.html # Bilingual sales page │ ├── graphoholic.html # Landing for Graphoholic campaign │ └── ad-spec.html # Design requirements (designers) ├── data/ │ ├── ads.db # SQLite database │ └── backup-expected.flag # Safety: crash on startup if backup missing ├── package.json ├── .env # Secrets (NOT committed) └── README.md
main branch
Earlier in development, a deploy bug wiped the database on every push. The fix: a flag file at data/backup-expected.flag. On startup, if the flag exists but the local DB doesn't, the server crashes intentionally instead of starting with an empty DB. This forces manual restore from Spaces backups.
Without this safety, any storage volume issue would silently wipe customer data. Worth the operational annoyance.
Single-server architecture. SQLite is faster (no network hop), simpler (one file), and handles >10K req/sec for read-heavy workloads. Migration plan exists: when concurrent writes become a bottleneck (~300K DAU) → migrate to Postgres.
SQLite has no WEIGHTED_RANDOM(). SQL workarounds are obscure and slow. Doing it in JS lets us add frequency capping, pacing, and inventory forecasting on the same in-memory list later.
Customer dashboards need to be easy. Passwords get forgotten, support tickets pile up. Token-in-URL is the same model Calendly uses: one tap, instant access. If a token leaks, deactivate from admin.
Every write to the DB schedules a backup to Spaces ~30s later. If another write comes in, the schedule resets. This batches bursts (10 events in 5s → 1 backup) and stays within DO Spaces bandwidth limits while keeping recovery point ≤30s.
Admin and customer dashboards are single HTML files with embedded JS. No React, no build step, no node_modules. Anyone with a browser dev tools can debug the UI. Pages load fast (cold start ~100ms). When a feature actually needs reactive state (rare), we add Alpine.js or similar — but not preemptively.
| Service | URL |
|---|---|
| Ad server (DO direct) | hebrew-calendar-ad-server-w4yu2.ondigitalocean.app |
| Ad server (custom domain) | ads.hebcal.co.il |
| Admin dashboard | ads.hebcal.co.il/admin/ |
| Customer dashboard | ads.hebcal.co.il/r/dashboard/<token> |
| Spaces bucket (origin) | hebrew-calendar-ads.ams3.digitaloceanspaces.com |
| Spaces CDN | hebrew-calendar-ads.ams3.cdn.digitaloceanspaces.com |
| Repository | Bitbucket: hebrew-calendar-ad-server |