How SBDMS is built
This page is for a developer who is new to SBDMS. It explains the shape of the system before you read any code. Read it first; the other developer pages assume it.
The three parts
Section titled “The three parts”SBDMS is one repository (govtprojs/swarnabindu) with three things that run in production:
| Part | Folder | What it is |
|---|---|---|
| Frontend | frontend/ |
Astro 5 in server-rendered mode (output: 'server', Node adapter), with Alpine.js for interactive parts and Tailwind for styling. It also registers a PWA service worker so static assets load fast on slow networks. |
| Backend | backend/Sbpdms.Backend/ |
A .NET 10 Web API. Controllers write plain SQL through Dapper against a single SQLite file. No Entity Framework. |
| Edge | the infra repository (govtprojs/sb-infra) |
Caddy 2 in Docker. It owns TLS, serves the public hostname, forwards /api/* to the backend and everything else to the frontend. |
All three run as Docker containers on one production VM, on a private Docker network. The database is a single SQLite file bind-mounted into the backend container. There is no Postgres, no Redis, no message queue.
Browser (phone or PC) | | HTTPS v +-------------------------------+ | Caddy (edge) | | /api/* ----------------+ | | everything else ---+ | | +---------------------|----|----+ | | v v +-----------+ +-----------------------+ | frontend | | backend (.NET 10) | | Astro SSR | | controllers -> Dapper | | port 4321 | | port 5090 | +-----------+ +----------+------------+ | v +-----------------+ | SQLite file | | app.db | +-----------------+ | Serilog JSON logs (daily files)How a request flows
Section titled “How a request flows”- The browser asks Caddy for a page, for example
/participants. - Caddy forwards it to the Astro server, which renders the page shell (layout, header, empty tables).
- The page’s Alpine.js script runs in the browser and calls the API through
frontend/src/services/api.js. The API base URL isPUBLIC_API_URL, baked in at build time. In production it is the relative path/api, so the browser calls the same host that served the page. - Caddy sees
/api/...and forwards it to the backend. - The controller checks the JWT, checks the caller’s role and site scope, runs SQL, and returns JSON.
The important consequence: Astro server code never calls the API for signed-in data. The JWT lives in the browser’s localStorage, and the Astro server cannot read it. Every authenticated call is made from a <script> or Alpine component in the browser.
Authentication and roles
Section titled “Authentication and roles”- Sign-in is
POST /api/auth/loginwith a username and password. The password is checked with BCrypt against the staff table (HPV). - On success the backend returns a JWT. It carries the user’s id, username and role, and expires after
Jwt:ExpireMinutes(30 days in production). The frontend stores it inlocalStorageand sends it asAuthorization: Bearer ...on every call. - The frontend logs the user out automatically when the token has less than two days left, and on any
401response. - There are four roles: SuperAdmin and GeneralAdmin (global), LocationAdmin and GeneralUser (scoped to camp sites). See Roles and what each can see for what each role does.
- Controllers use
[Authorize]for “any signed-in staff” and[Authorize(Roles = "...")]for role gates. Reference-data writes (regions, districts, units, ingredients, dose rules, exclusion criteria) are SuperAdmin and GeneralAdmin only.
The frontend has a RoleGuard that hides pages a role should not see. It is a convenience, not a security boundary. The server is the boundary.
Per-row scoping
Section titled “Per-row scoping”Role checks are not enough. A LocationAdmin must see only their own camp sites’ data. This is handled by one class, Services/PermissionService.cs:
- It reads the caller’s assigned sites from the
CampSiteHPVstable on the first call in a request and caches them for that request. It never trusts the site list inside the JWT, because that list can be stale. - It offers point checks (
CanAccessChildAsync,CanAccessCampDayAsync,CanAccessDosePrepAsync, and so on) and SQL fragment builders (ChildScopeWhereAsync,CampDayScopeWhereAsync) that controllers append to theirWHEREclauses. - Only integer ids from the database are ever inlined into SQL. User input is always a parameter.
- A scoped user with no assigned site is denied everything except children who have never had a dose session.
When a scoped user asks for a row they may not see, the controller returns 404, not 403. This keeps the existence of other sites’ records private.
Audit logging
Section titled “Audit logging”Every create, update and delete on compliance-critical tables (children, guardians, their links, staff, dose preparations, dose sessions) writes a row to AuditLog through IAuditService. Each row stores the table, the primary key, the action, who did it and their role, a JSON snapshot before and after, the endpoint and the caller’s IP. The application never updates or deletes from this table. A failed audit write is logged as an error but never fails the user’s request. Admins read it under Admin → Audit Log.
Logs and tracing
Section titled “Logs and tracing”The backend uses Serilog. Every request produces one line: method, path, user, status code, duration and a trace id. The same trace id is returned to the browser in the X-Trace-Id response header, so a user who reports an error can read the id from their browser and support can grep the logs for it.
Logs go to the console (visible with docker logs) and to compact JSON files under logs/ inside the backend’s working directory, one file per day. Files roll to a new part when they reach 256 MB and about four months of files are kept. Unhandled exceptions are caught by one middleware in Program.cs and returned as { error, message, traceId }, so clients never see an HTML error page.
Two unauthenticated probes exist: GET /healthz (process is up) and GET /readyz (database answers SELECT 1).
Bilingual UI
Section titled “Bilingual UI”The frontend keeps an Alpine store, $store.i18n, with two flags, showEn and showNe. The English and Nepali checkboxes in the header toggle them; at least one stays on. A template that supports both languages writes each label twice, one span per flag. In production this pattern is used where families and health workers read the screen: the self-registration form, the dose wizard, the reports pages, the child edit page and the camp-site view. Most admin lists are English-only today. Reference tables carry a NepName column next to Name, and dates are shown in both AD and BS using the nepali-date-converter package.
Production does not ship a translation dictionary; the store only has the two flags. Do not call $store.i18n.t(...) in shared components; it exists only on the QC environment. See Environments, branches and releases.
Offline and mobile
Section titled “Offline and mobile”The web app is online-only in production. Separately, a Flutter mobile app lives in sb-mobile/ on the mobile-backend branch. It is offline-first: it keeps a local Drift (SQLite) store and an outbox of pending writes that it replays when it has signal. It talks to its own backend deployment, not the production API, and that backend is a different build of the same code. Nothing on the production stack depends on it.
Where to read next
Section titled “Where to read next”- Data model for the tables.
- API overview for the endpoints.
- Running it locally to start coding.