Backend API for CampusPulse, shared by the mobile
and web clients.
- Stack: .NET 10 (ASP.NET Core) · PostgreSQL 18 · EF Core 10 (Npgsql)
- Contract:
openapi/v1.json, committed. The clients generate their typed API client from it (see API contract); the running API also serves it at/openapi/v1.jsonin every environment.
src/CampusPulse.Domain entities + pure logic. No dependencies, no EF, no web.
src/CampusPulse.Infrastructure EF Core, DbContext, (later) migrations.
src/CampusPulse.Api ASP.NET Core host, DI wiring, endpoints.
tests/CampusPulse.Domain.Tests unit tests for the pure logic.
tests/CampusPulse.Api.Tests in-process tests of the HTTP surface (WebApplicationFactory).
Environment variables only:
| Variable | What |
|---|---|
ConnectionStrings__Postgres |
Npgsql connection string. Required: startup fails fast without it. The database does not have to be reachable for the API to start. |
Cors__AllowedOrigins__<n> |
browser origins allowed to call the API; https://*.example.com allows any subdomain (Cloudflare PR previews). None set = no browser origin allowed |
Logging__File__Path |
also write the logs to this file (see Logs). Unset = stdout only. If set and the file can't be written, startup fails |
Logging__File__FileSizeLimitBytes |
roll the file at this size; default 10485760 (10 MB) |
Logging__File__RetainedFileCountLimit |
files kept, the live one included; default 10 |
Logging__LogLevel__<category> |
the usual .NET log levels, for stdout and the file alike; defaults in appsettings.json (Information, Microsoft.AspNetCore at Warning, Microsoft.AspNetCore.DataProtection at Error: sign-in registers Data Protection, which nothing uses (bearer tokens only), and with no home directory, as for the Pi's service users, it warns at every start that its keys are in memory. Lower it again once a feature protects data across restarts; Microsoft.EntityFrameworkCore at Warning, so SQL commands aren't logged: Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command=Information shows them while debugging) |
Auth__TenantId |
UPT's Entra tenant id (6bb41fe4-40a3-4a10-b6cd-38278e78b21a). With Auth__ClientId unset too, sign-in is off and every endpoint that needs it answers 401 (see Sign-in) |
Auth__ClientId |
the app registration's client id (one registration for web, mobile and the API) |
HTTPS_PROXY, NO_PROXY |
on the Pi, the egress proxy the API reaches Microsoft and Expo through (see Sign-in, Push notifications) |
Push__Expo__AccessToken |
optional: an Expo access token, sent as Authorization: Bearer to Expo's push API. Needed only once "enhanced security for push notifications" is turned on for the Expo project |
Students sign in with their UPT Microsoft account. The clients (MSAL in web, expo-auth-session in
mobile) get an access token for this API from UPT's tenant and send it as
Authorization: Bearer …. Web, mobile and the API are one app registration, so one client id.
Every endpoint needs a token except /, /health* and /openapi/v1.json: a fallback policy
protects anything not marked .AllowAnonymous(), so a new endpoint is protected by default, and the
contract marks every other operation as needing the bearer token. The API accepts a token only if it:
- is signed with RS256 by Microsoft (keys from the tenant's metadata);
- has the issuer
https://login.microsoftonline.com/<tenant>/v2.0exactly (v2 tokens; see below); - has an audience of the client id or
api://<client-id>, and was issued to this app (azp= the client id); - has
tidequal to the tenant, and noidpother than the tenant's own: guests are refused; - has
access_as_userinscp, so app-only tokens and ID tokens are refused; - has an
oidand hasn't expired.
Anything else is 401, logged at Warning with the reason (never the token).
- Who gets in: every member of the tenant, students and staff alike. Students only would need a further rule (for example on the account's domain or a group); that's a product decision.
POST /mecreates the signed-in student's account (keyed byoid), or returns the one they have: 201 when it was created, 200 when it existed, with the body{ id, email, displayName }either way.GET /mereturns that same body, and never creates an account: no account answers 404 (ProblemDetails). Both refresh email and name when the token carries them.DELETE /medeletes the signed-in student's account and everything stored about them (see Data) and answers 204, also when there was nothing to delete, so retrying is safe.- What clients must do:
- Call
POST /meonce, right after an interactive sign-in. Never on app start or after a silent token refresh. Otherwise a second device that still holds tokens would silently re-create an account the student deleted on the first. - Treat a 404 from
GET /meas "this account no longer exists" (deleted, maybe from another device): sign out locally, drop cached data and show sign-in. - After a 204 from
DELETE /me, sign out locally right away: drop the tokens and the cached data. The access token stays valid until it expires. - Send a random
X-Request-Idper request, or none, never one fixed per install: a stable id would tie all of a student's log lines together, a deleted student's included.
- Call
- Unconfigured (no
Auth__TenantId/Auth__ClientId): the API still starts and logs a warning, and every endpoint that needs sign-in answers 401. It never lets a request through anonymously. - At startup the API loads the tenant's metadata in the background: one Information line if it can, an Error if it can't reach Microsoft. It still starts, and requests with a token answer 500 (a server fault, so clients don't send the student back to sign in) until the metadata loads.
- Clients use the authority
https://login.microsoftonline.com/<tenant>(not/common, which a single-tenant app refuses with AADSTS50194) and request the scopesopenid profile offline_access api://<client-id>/access_as_user. Withoutprofilethe token may lack the name and email;offline_accessgives the refresh token.
Registering the app (once, by Sebastian, signed in with his @student.upt.ro account): a test
on 2026-10-14 showed UPT lets students register apps and consent to their own organisation's apps.
- entra.microsoft.com → App registrations → New registration: name
CampusPulse, single tenant (UPT accounts only). - Authentication:
- Single-page application redirect URIs:
http://localhost:5173,https://staging.campuspulse-web.pages.devandhttps://campuspulse.thorsp.net. - Mobile and desktop applications:
campuspulse://auth. Any app on a phone can claim a custom scheme; PKCE (expo-auth-session's default) is what keeps a code caught there useless. - Allow public client flows: No. The SPA and mobile flows don't need it; it only enables device-code and password flows.
- PR previews (
pr-<n>.campuspulse-web.pages.dev) can't be registered one by one, so they can't sign in.
- Single-page application redirect URIs:
- Expose an API: set the Application ID URI to the default
api://<client-id>, and add the scopeaccess_as_user(who can consent: admins and users). - Manifest: set
"requestedAccessTokenVersion": 2(inapi). Without it Entra issues v1 tokens, whose issuer ishttps://sts.windows.net/<tenant>/, and the API refuses them. - On the Pi: add
Auth__TenantId=6bb41fe4-40a3-4a10-b6cd-38278e78b21aandAuth__ClientId=<client-id>to both env files (/srv/campuspulse/.config/campuspulse/api-{stage,prod}.env), then restart the services. The client id isn't a secret; record it in the Pi's credential registry when it's created. The Pi's network guard lets the API out only through the egress proxy, so the env files also needHTTPS_PROXY=http://127.0.0.1:8899andNO_PROXY=127.0.0.1,localhost(see deploy/pi/README.md), and the proxy's allow list must holdlogin.microsoftonline.com. Without them the startup check logs an Error and every signed-in request fails.
docker compose up -d # Postgres on 127.0.0.1:5434
dotnet run --project src/CampusPulse.Api # http://localhost:5280 (connection string from launchSettings.json)GET /health→{"status":"ok"}(liveness; does not touch the database)GET /health/ready→{"status":"ok"}, or 503{"status":"unavailable"}when the database can't be reached within 3 s (readiness; the deploy checks this one)GET /openapi/v1.json→ the API contract
openapi/v1.json is written by the build (Microsoft.Extensions.ApiDescription.Server) and
committed. Regenerating it is just dotnet build: commit the changed file with the change that
caused it. If the file didn't change after an API change, run dotnet build --no-incremental. CI
deletes the committed copy, rebuilds it and fails when the result differs ("run dotnet build and
commit openapi/v1.json"); that run also uploads the regenerated file as the openapi-v1 artifact,
so the fix can be "download and commit". Never edit it by hand. Only per-architecture builds (for
example the linux-arm64 migrations bundle) skip writing it.
The SDK is pinned by global.json (10.0.4xx band, latest patch), so every machine and CI generate
the same JSON.
The clients read it straight from GitHub, so they need no token:
bunx openapi-typescript https://raw.githubusercontent.com/CampusPulse-ro/api/main/openapi/v1.json \
-o src/api/schema.d.tsand call the API through openapi-fetch with those types.
main in that URL can be a release tag (…/api/v0.2.0/openapi/v1.json) to pin a client to a
released contract. List every endpoint change in the PR's "API contract" section, since the
clients follow main.
Writing endpoints so the generated types are right:
- Write response DTOs as positional records (
record HealthResponse(string Status)) or withrequiredmembers; otherwise their fields come out optional (?) in TypeScript. - Integers are serialized as numbers only (
JsonNumberHandling.Strict) and enums by their camelCase name (JsonStringEnumConverterwithJsonNamingPolicy.CamelCase:PushPlatform.Iosis"ios"), so they becomenumberand string unions, notnumber | string. - Request bodies are read as strictly as the contract describes them: every constructor parameter of
a body record must be present (
RespectRequiredConstructorParameters) and an enum is accepted only by name, never as a number. A body that can't be read is a 400 ProblemDetails (an empty 400 gets one fromUseStatusCodePages), in every environment. - Declare every response (
.Produces<T>(),.Produces<T>(503)) so clients get a type for it.
users: one row per student who has signed in (oid, email, name, first and last seen).- Everything about a student refers to their
usersrow with ON DELETE CASCADE.DELETE /medeletes that row, and the database deletes the rest with it: settings, device push tokens, Campus Virtual tokens, any later table. In EF that's.OnDelete(DeleteBehavior.Cascade), the default for a required relationship;ClientCascadeis not enough, because the delete doesn't go through EF's change tracker. The rule covers the whole tree underusers: a table that refers to a per-user table (device tokens under devices) cascades from it too.PerUserDataTestsfails the test run if any key on a path fromusersdoesn't cascade, both in the EF model and in the migrated database (pg_constraint), so a key written by hand in a migration is checked too. - Per-user data must refer to
usersby foreign key. A column that only stores the oid or the email, with no foreign key, isn't covered: nothing deletes it. Data that can't have a key (a file, another service) needs its own step inAccountDeletion, added in the same PR as the data. push_devices: a student's phones, by Expo push token (unique), with platform and first and last registration (see Push notifications).push_receipts_pending: tickets of sent notifications whose receipt hasn't been read yet, under their device. Both are in the cascade tree:users→push_devices→push_receipts_pending.- The timetable (see Timetable):
semesters(withteaching_weeksandnon_teaching_days),programmes,study_groups,rooms,subjects,timetable_slots(withtimetable_slot_groups),offered_slots,timetable_notesandtimetable_import_runs. Written by the import only. A student's own rows arestudent_settings(their study group) andstudent_subjects(optional subjects and picked offers), both in the cascade tree underusers.- The import never deletes a study group or a subject. One a newer workbook no longer has is
retired (
retired_at), so the student's row keeps pointing at it, shown as "no longer offered". - It does delete offered slots that changed or went:
student_subjects.offered_slot_idisSET NULL, so that clears the pick, not the choice. student_settings.study_group_idisSET NULLandstudent_subjects.subject_idisCASCADE. Those only matter if a group or subject row is ever deleted by hand.
- The import never deletes a study group or a subject. One a newer workbook no longer has is
retired (
account_deletion_counts: the number of accounts deleted per UTC day, added to in the same transaction as the delete. It holds no identifier. The deletion's log line (Account deleted) names no one either, but like every line of the request it carries the request's scope: request id, trace and span ids, connection id and path.- Backups: the production backups dump this database nightly and keep 7 daily, 5 weekly and 12
monthly copies, plus a weekly copy on a second machine. A deleted account's row therefore
survives in backups for up to about a year after the delete.
- Decided (2026-10-14): retention stays as it is. Where users are told (the deletion page, Play's data safety form): deleted data stays only in the system-wide backups, is never used for anything, and is gone after at most about a year.
Sent through Expo's push service
(IPushSender, implemented by ExpoPushSender; FCM/APNs directly would be another implementation).
Every endpoint needs sign-in, and the first and last need an account (404 like GET /me otherwise).
POST /me/devices{ token, platform }(platformis"android"or"ios"): registers the app's Expo push token for the student. 201 when it's new, 200 when it was known (its last-registered time is refreshed). 400 ProblemDetails unlesstokenlooks likeExponentPushToken[…]orExpoPushToken[…](letters, digits,_,-), and 400 too whenplatformis missing or isn't one of the two names (a number isn't accepted). A token registered by another student moves to this one (another student signed in on the same phone). A student keeps at most 10 devices: registering an 11th drops the least recently registered.DELETE /me/devices{ token }: forgets that device. 204 for any token, also one that isn't the student's (nothing happens), so the answer doesn't tell whose a token is; 400 only when the body isn't{ token }. The token goes in the body, not the path, because paths are logged here and by the reverse proxy, and push tokens never are.POST /me/notifications/test: sends "CampusPulse test notification" to the student's devices and answers{ sent }, the number the push service accepted it for. Once a minute per student: 429 withRetry-Afterotherwise. Only a send that reached the push service counts, so aftersent: 0with no devices, or a 502, the next tap sends right away. The limit is kept in memory: a restart forgets it, each API instance would count on its own, and an entry is dropped as soon as its minute is over. 502 when the push service can't be used at all.
What the clients do:
- Register (
POST /me/devices) on every app launch (Expo's advice: it keeps the token fresh), afterPOST /me, and whenever the Expo push token changes (addPushTokenListener). - Delete it (
DELETE /me/devices) on sign-out, before dropping the access token. DELETE /meremoves every device with the account.
Sending, tickets and receipts:
- Messages go to Expo in batches of 100, through a typed HttpClient with a 15 s timeout.
- Expo answers each message with a ticket. A
DeviceNotRegisteredticket (the app was uninstalled) deletes that device at once. An accepted one is kept inpush_receipts_pending. - A background service (
PushReceiptChecker) runs every 15 minutes. It reads the receipts of the tickets that are at least 15 minutes old, in batches of 300, each applied on its own in one transaction: first the devices whose receipt isDeviceNotRegistered, then the answered rows. A batch Expo can't answer keeps its rows for the next pass. Rows older than 24 hours are dropped unread. With nothing pending, a pass is one query. - A device that registered again after a message was sent is never deleted for that message. A
reinstall can bring the same Expo token back, so a late
DeviceNotRegistered(ticket or receipt) deletes the device only if its last registration is older than the send.
Logs carry counts and Expo's error codes (DeviceNotRegistered, MessageTooBig, a failed
request's PUSH_TOO_MANY_EXPERIENCE_IDS…) only, never a push token, user id or email. Expo's own
error texts and details quote tokens, so they're never kept, and a code that isn't ASCII letters and
_ is logged as Unknown.
Network: the push client sets no proxy of its own. It uses
HttpClient.DefaultProxy,
which on Linux comes from HTTPS_PROXY / NO_PROXY; a test checks the handler keeps that
default. So on the Pi it goes through the egress proxy, whose allow list has exp.host
(deploy/pi/egress-allow; installing it on the Pi is a manual step, see
deploy/pi/README.md).
Android needs an FCM key at Expo (not part of this API): Expo delivers to Android through
Firebase Cloud Messaging, so an FCM V1 service account key has to be uploaded to EAS for the app's
Android credentials (eas credentials). Sebastian does that. Until then Expo can't deliver to
Android: expect an InvalidCredentials error on the ticket or on its receipt, in the logs.
The AC faculty's timetable for a semester (#5), loaded by a one-off import: docs/timetable-import.md. Every endpoint needs sign-in: the faculty shares these files with UPT accounts only. Dates are Romanian dates. The API expands the weekly slots into dates itself (teaching weeks, the break, non-teaching days, each slot's weeks), so no client handles week parity.
GET /semesters/current: the teaching weeks with their dates, non-teaching days (reason), the breaks (from,to,name, null when unnamed), today and its teaching week. Calendar names come as{ ro, en }: show the one of the UI language (enrepeatsrowhen the timetable gives no English name).GET /timetable/programmes: programmes → years → study groups, to pick a group from.GET /me/settings/PUT /me/settings{ studyGroupId }(null clears it): the student's group. 400 for a group that doesn't exist, 404 without an account.GET /timetable/groups/{id}/choices: the subjects of that group's year a student chooses, each with the slots it's offered at:optional(opt in, then pick a slot);pickSlot(everyone has it, at a time they pick: most master labs).
GET /me/subjects,PUT /me/subjects/{subjectId}{ offeredSlotId }(null: taking it, no slot picked yet),DELETE /me/subjects/{subjectId}. 400 for a subject with nothing to choose or an offer of another subject.GET /me/timetable?from=&to=: the student's classes, both dates included, at most 31 days, defaulting to today. That's their group's classes, minus optional subjects they didn't choose, plus the offered slots they picked; plus the notes of their year (sport, tutoring hours, rotas written out).GET /timetable/groups/{id}?from=&to=: a group's classes, optional subjects included (optional).GET /rooms,GET /rooms/{code}/timetable?date=: everything in a room on a date, grid slots and offered slots.
Clients: read the student's group from GET /me/settings after POST /me. With no group, send them to
the picker. With noLongerOffered (a newer timetable doesn't have it) or fromAnotherSemester (a new
semester was imported), ask them to pick again. A chosen subject with noLongerOffered is shown as such.
Every class, subject and room entry carries campusVirtualUrl: the subject's course page on Campus Virtual
(cv.upt.ro), or null when it has none we know of. Link to it as given; don't build the URL.
The schema is versioned with EF Core migrations in src/CampusPulse.Infrastructure/Migrations/
(ORM: EF Core 10 + Npgsql, snake_case names). A model change is committed together with its
migration.
dotnet tool restore # dotnet-ef, pinned in dotnet-tools.json
dotnet ef migrations add <Name> --project src/CampusPulse.Infrastructure --startup-project src/CampusPulse.Api-
Make migrations on a development machine (ThorSPB or ThorSPC), never on the Pi.
-
A migration must keep working with the previous release: expand first (add the new column or table), contract in a later release (drop the old one). A rollback restores the code, not the schema.
-
dotnet efcreates the context withDesignTimeDbContextFactory, which takes the connection string fromConnectionStrings__Postgresin the environment.migrations add,has-pending-model-changesand building a bundle don't connect and need no value;database updateand running a bundle do. -
To migrate the local Compose database:
ConnectionStrings__Postgres="Host=localhost;Port=5434;Database=campuspulse;Username=campuspulse;Password=campuspulse_dev" \ dotnet ef database update --project src/CampusPulse.Infrastructure --startup-project src/CampusPulse.Api -
CI runs
dotnet ef migrations has-pending-model-changes: a model change without its migration fails the build. -
CI also builds a migrations bundle (
efbundle, self-contained linux-arm64, artifactcampuspulse-efbundle). Each deploy runs it against its environment's database, with the connection string from the environment (never in argv), before the new release goes live, so the Pi needs neither the SDK nordotnet-ef.
- Stdout: .NET's JSON console lines (journald on the Pi): UTC
Timestamp,LogLevel,Category,Message, andScopes, where every line logged during a request has itsRequestId. - File: when
Logging__File__Pathis set (the Pi's units set/srv/campuspulse/logs/<env>/api.log, the only folder the service can write), the same events also go to a file, at the same levels. It rolls at 10 MB and keeps 10 files:api.logfirst, thenapi_001.log,api_002.log… (the newest is the highest number; the oldest is deleted). The Pi's journald keeps logs in RAM only, so the file is what survives a reboot. Locally it's off unless you set it. Lines are Serilog's rendered compact JSON:@t(UTC time),@m(the message, with string values in quotes:HTTP "GET" "/" responded 200 in 3.1 ms),@l(the level, left out at Information),@x(an exception with its stack),@i(an id of the message template), then the event's properties (RequestId,SourceContext= the category,StatusCode…). - One line per request: method, path (never the query string), status and duration. A passing
/health*check (status below 400) is logged at Debug, since deploys and monitoring call them constantly; a failing one (a 503 readiness) at Information like any other request. - Request id: a caller's
X-Request-Idis kept if it's 1–64 characters of letters, digits,.,_,-or:; otherwise a new one is made. It is the request'sTraceIdentifier, returned inX-Request-Idon every response (exposed to browser scripts through CORS), so a client's bug report can be matched to its log lines. - Errors: an unhandled exception returns a ProblemDetails 500 with no exception details, whose
requestIdis the request id (andtraceIdthe W3C trace id, for tracing tools); the exception and its stack trace are logged at Error. - Reading them on the Pi:
journalctl -u campuspulse-api-stage -o cat | jq, or the file, which follows the newest one:sudo sh -c 'tail -F "$(ls -t /srv/campuspulse/logs/stage/api*.log | head -1)"' | jq -c '[."@t", ."@l", ."@m", .RequestId]'. Look one request up withsudo grep -h '"RequestId":"<id>"' /srv/campuspulse/logs/stage/api*.log | jq. - Error reporting (Sentry): not enabled.
Program.csmarks where it would go; it would also need an allow rule in the Pi's network guard.
dotnet testTests marked [PostgresFact] need a real Postgres. Point CAMPUSPULSE_TEST_DB at a server you
can create databases on (no database name in it). Their fixture, PostgresAppFactory, creates a
database of its own, migrates it, runs the app on it and drops it afterwards. For the Compose database:
docker compose up -d
CAMPUSPULSE_TEST_DB="Host=localhost;Port=5434;Username=campuspulse;Password=campuspulse_dev" dotnet testWithout the variable those tests show as skipped, with the reason. On CI (CI=true) they are
never skipped: CI runs a Postgres 18 service and sets the variable.
Formatting follows .editorconfig: run dotnet format before committing; CI fails on unformatted code.
CI (.github/workflows/ci.yml) checks formatting, builds with warnings as errors, checks for
pending model changes, runs the tests against Postgres, publishes the app and the migrations
bundle as artifacts, and builds the container image for linux/amd64 and linux/arm64
without pushing it.
| stage | prod | |
|---|---|---|
| URL | https://campuspulse-api-stage.thorsp.net | https://campuspulse-api.thorsp.net |
| Deployed by | every merge to main (a green CI run) |
a published GitHub Release vX.Y.Z |
| Used by | web PR previews and staging, mobile dev and preview builds | web production, mobile production builds |
-
Releasing: publish a GitHub Release with a
vX.Y.Ztag on a commit that is onmain(only admins can createv*tags). TheReleaseworkflow builds it, attests the build and attachescampuspulse-api.tar.gzandefbundle(the migrations bundle); the Pi verifies the attestation and deploys exactly that version to prod. Pre-releases and drafts are never deployed. Cut one withgh release create vX.Y.Z --target main --generate-notes: the notes group merged PRs by label (enhancement/feat,bug,dependencies, the rest), configured in.github/release.yml. Label PRs so they land in the right group. -
Rolling back: on the Pi,
campuspulse-deploy rollback prod(orstage) switches to the healthy release before the live one. It restores code, not schema. To roll forward again:campuspulse-deploy prod <run-id> vX.Y.Z --force. -
How the Pi side works and how it was set up:
deploy/pi/README.md. -
There is no Dockerfile. Hosts that only run images get one from the SDK (
dotnet publish -t:PublishContainer), multi-arch (linux-x64+linux-arm64) from the csproj:dotnet publish src/CampusPulse.Api -c Release -t:PublishContainer \ -p ContainerRegistry=ghcr.io -p ContainerRepository=campuspulse-ro/campuspulse-api