Private client repository. Permalinks below point to security-scrubbed excerpts in
dbnathan/nathan-code-samples.
A B2B SaaS for employee engagement surveys, positioned against OfficeVibe and Supermood. Three-container Docker architecture — React frontend, Node API, Python analysis service — on a 32GB Scaleway VPS, with the entire dataset held inside MongoDB Atlas's 512MB free tier by materializing every dashboard aggregate at write time. Five focused weeks, two production tags, and a pre-release audit that surfaced four critical issues nobody had noticed, including one silently downgrading every new paid account to the free plan.
Context
The product is a survey engine that asks employees questions on a recurring cadence, computes engagement scores, and runs an analysis pass over the verbatim answers. Constraints:
- MongoDB Atlas M0 free tier. A hard 512MB ceiling with no soft-fail, which dictates the entire read path. See the next section.
- GDPR hard rules. No manager-level alert below 5 respondents. No cross-dimension analysis below 3. Every verbatim is regex-anonymized (email, French phone numbers) before it ever leaves the API layer.
- Solo dev, no CI/CD. Manual
rsync + docker compose build. No test automation (Jest/pytest both absent). All quality control runs through manual Playwright passes and a pre-release audit document. - Inherited migration. Came from a Vercel + Supabase + AWS SDK stack that had been ripped out before the first commit in this repo. The visible git history starts at the V2 production build — five weeks of prior work are squashed into the initial commit.
Staying inside 512MB
MongoDB Atlas M0 caps at 512MB and there is no soft-fail: writes start rejecting. The read path is therefore not allowed to compute anything. Every number a dashboard renders already exists as a document by the time the page loads.
Each survey response triggers a bulkWrite of pre-aggregations — per-question, per-dimension, per-period — written in the same request that stores the raw answer, with the alert hook fired and forgotten so it cannot extend write latency (analyticsService.js).
Stress-tested at 537 employees, 22 surveys, 72,786 responses and 14,322 pre-aggregations, still under quota. The trade is the usual one: writes got heavier and less flexible, reads became constant-time, and adding a new dashboard dimension is now a migration rather than a query. The retrospective at the end covers where that stops being the right trade.
Stack
| Layer | Choice | Why this one |
|---|---|---|
| Frontend | React 18 + CRA + Tailwind + DaisyUI + Recharts | Inherited; kept for delivery speed |
| Backend | Node 20 + Express 4 + Mongoose 8 | Pragmatic; already running in V1 |
| Database | MongoDB Atlas M0 (free) | Cost zero until 512MB — pre-aggregations compensate |
| Auth | JWT (jose) + bcrypt, 24h expiry | Reduced from 7d during the hardening pass |
| Analysis service | Python 3.11 + FastAPI + anthropic>=0.49 + scikit-learn 1.3 | Isolated runtime to contain cost and latency |
| Payments | Stripe 17 (Basic / Pro, test mode pending KYC) | Standard B2B SaaS |
| Hosting | Scaleway VPS (32GB / 6 cores) + Docker + nginx + Let's Encrypt | Fixed cost, full control |
| Monitoring | monitor-health.sh cron + Docker healthchecks | Minimum viable solo — no APM, no Sentry |
| Brevo (SDK legacy) | Inherited — sender unverified, see the retrospective | |
| Exports | pdfkit (chose over puppeteer — Alpine incompatibility) | One of the cleaner forced pivots |
Security posture
Set during the pre-release hardening pass, before the audit rather than after it (server.js):
helmetfor the standard header set,express-mongo-sanitizeagainst operator injection in query bodies.- Three rate-limit tiers rather than one global cap. Auth routes, write routes and read routes have different abuse profiles, and a single limit is either too loose for the first or too tight for the third.
- JWT expiry cut from 7 days to 24 hours. A week-long token on a product with no revocation list is a week-long window.
- bcrypt on passwords, no exceptions and no legacy path.
What was still missing is enforcement symmetry between frontend and backend — which is exactly what the audit found next.
The plan-gating incident
Discovered during the V3 pre-release audit, not in production — the user base was tiny enough that no real customer had hit it yet, but every freshly-signed-up Pro account was silently degrading itself to Basic.
Root cause
The JWT issued by registerStepOne contained { email, id, userType } but not companyId. The endpoint /api/v2/plans/me required companyId to resolve the user's plan and returned 401 without it. On the frontend, PlanContext caught the 401 and fell back silently to currentPlan: 'basic' — instead of bubbling up the error.
Net effect: paid Pro features were gated off for new accounts. Old sessions kept working because their JWT pre-dated the change.
Fix
Single commit, cf5c9ea. Introduces a helper resolveCompanyId(req) that looks up companyId via Admin.findById(req.user.id).companies[0] instead of trusting it from the JWT. Applied to /plans and to /stripe (which had the same bug on subscription routes — same root cause, same fix).
Bonus discovery in the same audit: two backend routes were doing the requireFeature('benchmarks_internal') check in the frontend only. A motivated user could have called the API directly. Patched in the same commit, before it ever got noticed externally.
The analysis pipeline
Non-deterministic work behind a plan feature flag, run once per completed survey. Seven steps, each isolated so a failure in the middle leaves no partial state:
- Collect verbatims from the database by
sentSurveyId. - Anonymize with two regex passes — emails and French phone numbers — in 12 lines of Python. The shortest, most-important file in the codebase.
- Sentiment batch. Chunks of 20 verbatims numbered in a single prompt, one JSON response per chunk. Cuts the per-verbatim API cost by ~20x. Gap analysis catches verbatims whose sentiment contradicts the quantitative rating (
sentiment_service.py). - Theme clustering via a single prompt that clusters, summarizes, and recommends in one pass (
theme_service.py). - Drivers analysis.
RandomForestRegressor(n_estimators=100, max_depth=5)over the per-employee scores, withX.corrwith(y)to recover the sign of each correlation — feature importance alone tells you the magnitude but not the direction (driver_service.py). - Risk + weak signal detection from the previous stages.
- Atomic upsert into
ai_analyses, indexed bysentSurveyIdso reruns are idempotent.
The whole pipeline is orchestrated in one file, commented step by step. If something fails in the middle, the upsert never happens — the next call retries from the top cleanly.
What it costs to run
Every model call is tagged with an action label, and the spend was tracked across 212 real API calls. In EUR, model claude-sonnet-4-20250514:
| Action | Per-call cost |
|---|---|
| Full analysis (12 sentiment batches + 1 themes + 1 drivers) | €0.234 |
| PDF report generation | €0.011 |
| Chatbot message (simple → complex) | €0.003 → €0.008 |
| Recommendation generation | €0.018 |
Projected monthly cost for an intensive client (4 analyses + 2 reports + 300 chats + 4 reco runs): €2.41/month. Total spend over the entire measurement window: €2.86.
The measurement is the point, not the figure. Batching sentiment into chunks of twenty, and splitting sentiment from theme clustering, is what moves the per-analysis cost by an order of magnitude — and neither change is visible without per-call instrumentation. Pricing a product on an unmeasured variable cost is how you find out at scale.
What's worth reading
analyticsService.js— pre-aggregationbulkWritewith fire-and-forget alert hookserver.js— security posture (helmet, sanitize, three rate-limit tiers)engagementScoreService.js— adaptive weighting with graceful fallback when the analysis pass is unavailableanalyze.py— the seven-step pipeline, commenteddriver_service.py— RandomForest + signed correlationanonymizer.py— 12 GDPR-critical lines
Retrospective
Five things I would change today:
- Tests should exist. Zero automated tests for 21 routes, 19 controllers, 10 services, 16 collections. The pre-release audit found four critical issues a baseline
supertest + pytestharness would have caught for free. The reason there are none is honest — I prioritized shipping over coverage — but that math flips fast. - MongoDB M0 is too tight. Pre-aggregations buy headroom, but one more dimension or one multi-tenant client and the quota cracks. M10 at ~$57/month is the obvious move the moment Stripe goes live.
- The Brevo sender has been unverified for two months. The reminder engine — one of the most visible Pro features — ships emails from a personal Gmail address. SPF/DKIM are at zero, deliverability is silently degraded, and this is a product risk more than a technical one. It needs the client to do the DNS work, which is a different kind of blocker than I'm used to handling.
- Cost instrumentation belongs in the codebase, not outside it. The €2.86 figure exists because I logged every call manually, outside the application. That data should live in a table next to
ai_analysesand be exposed in the admin UI. The unit economics of this product depend on that number; measuring it out-of-band is a gap I would close first. - CRA → Vite. The frontend carries four icon libraries (heroicons + lucide + react-tagcloud + wordcloud) and 20 orphan components flagged by the audit. A migration to Vite plus a one-afternoon cleanup would cut cold start and bundle size meaningfully. The reason it hasn't happened is purely "later".
build
Solo build, 11 visible commits over six weeks (history scrubbed for leaked secrets). Architecture, debugging, and incident response are mine; a coding agent wrote most of the first-draft code and every diff was reviewed before merge — the setup is documented at /projects/portfolio. The plan-gating bug above is what the pre-release audit is for: a JWT missing one field, silently degrading every new Pro account to Basic, caught before a single paying customer hit it. The same pass found a second instance of the same root cause in a different route. Neither would have surfaced from reading a diff — they surfaced from testing the actual account states.