| 1 | # nexo / orbit — Stack Overview
|
| 2 |
|
| 3 | > **nexo** (repo `/orbit`, app slug `orbit`) is an academic (TCC/monografia) mood- and
|
| 4 | > bipolar-disorder (TAB) tracker. Users log moods, sleep, triggers, medicine regimens and
|
| 5 | > "care actions"; the backend produces auto-generated insights, sends push/email reminders
|
| 6 | > and generates clinician-ready PDF reports.
|
| 7 | >
|
| 8 | > The repository is the **online/academic** version. It is a client–server app; the
|
| 9 | > "final, offline" app is planned for a separate repo.
|
| 10 |
|
| 11 | > **Visual:** open `docs/architecture.excalidraw` (excalidraw.com / tldraw import) for a
|
| 12 | > component-and-flow diagram of the whole stack.
|
| 13 |
|
| 14 | ---
|
| 15 |
|
| 16 | ## Repository layout
|
| 17 |
|
| 18 | ```
|
| 19 | nexo/
|
| 20 | ├── api/ # Backend — Node.js + Express REST API (all business logic)
|
| 21 | ├── frontend/ # Mobile app — React Native (Expo) source code
|
| 22 | ├── monografia/ # LaTeX thesis (auto-compiled to PDF by CI)
|
| 23 | ├── ansible/ # One-shot VPS provisioning (bootstrap, docker, deploy user)
|
| 24 | ├── .github/workflows/ # CI/CD: test → build → deploy; thesis PDF publishing
|
| 25 | ├── docker-compose.yml # Local/dev infra: Postgres + Valkey + API
|
| 26 | ├── etc/valkey.conf # Valkey configuration mounted by compose
|
| 27 | └── docs/ # misc (this file, class notes)
|
| 28 | ```
|
| 29 |
|
| 30 | ---
|
| 31 |
|
| 32 | ## docker-compose.yml — local/dev infrastructure
|
| 33 |
|
| 34 | | Service | Image | Port | Purpose |
|
| 35 | |---|---|---|---|
|
| 36 | | `db` | `postgres:17-alpine` | host `5430` → `5432` | Relational database, health-gated |
|
| 37 | | `valkey` | `valkey/valkey:9.0-alpine` | `6379` | Redis-compatible server: BullMQ job broker + repeatable schedules |
|
| 38 | | `api` | `./api` (dev Dockerfile) | `3000` | Backend with `nodemon` hot reload; bind-mounts `./api` and `api/.env.development` |
|
| 39 |
|
| 40 | Notes:
|
| 41 | - `api` only starts after `db` and `valkey` report healthy.
|
| 42 | - The mobile app (`frontend`) is **not** part of compose — it's an Expo app run/devved
|
| 43 | separately and reaches the API over `EXPO_PUBLIC_API_BASE_URL`.
|
| 44 |
|
| 45 | ---
|
| 46 |
|
| 47 | ## API — `api/`
|
| 48 |
|
| 49 | **Stack:** Node.js 24 · TypeScript 6 · Express 5 · Prisma 7 · PostgreSQL 17 · BullMQ +
|
| 50 | Valkey · zod · pino · Resend · AWS S3 SDK · @react-pdf/renderer · Vitest.
|
| 51 |
|
| 52 | ### Architecture (layered)
|
| 53 | ```
|
| 54 | routes/ → thin HTTP wiring (path → controller fn)
|
| 55 | controllers/ → parse/validate request, call services, shape responses
|
| 56 | services/ → business logic & persistence
|
| 57 | schemas/ → zod input validation (auth, mood, trigger, medicine, etc.)
|
| 58 | middleware/ → requireAuth (JWT), errorHandler
|
| 59 | lib/ → prisma client, jwt, mail, s3, queue registries, push
|
| 60 | reports/ → React-PDF report generation (JSX document + router)
|
| 61 | ```
|
| 62 |
|
| 63 | - **Context of data:** every resource is scoped to `req.userId` set by `requireAuth`
|
| 64 | (Bearer JWT). Passwords hashed with `bcryptjs`.
|
| 65 | - **Errors:** domain error classes map to HTTP statuses; middleware also handles Prisma
|
| 66 | errors (`P2025` → 404, `P2002` → 409); fallback 500. Validation errors returned with a
|
| 67 | `fields` map.
|
| 68 | - **Validation:** zod schemas + `zod-validation-error`; report endpoint enforces
|
| 69 | date-range ≤ 90 days.
|
| 70 | - **Logging:** `pino` + `pino-http` structured logs (pretty in dev via `pino-pretty`).
|
| 71 |
|
| 72 | ### Data model (Prisma, PostgreSQL)
|
| 73 | - `users` — profile, encrypted password, activation code, push token, notification prefs,
|
| 74 | daily reminder time, avatar (S3 key/URL).
|
| 75 | - `moods` + `mood_components` — mood rating (enum `SAD/NEUTRAL/GOOD/GREAT/ANGRY`) with
|
| 76 | anxiety/stress/energy levels + optional emotion components w/ intensity.
|
| 77 | - `triggers` + `trigger_mood_links` — trigger events (category enum) optionally linked to
|
| 78 | moods with a perceived impact.
|
| 79 | - `medicine_regimens` + `medicine_logs` — medication schedule (times of day, cadence enum)
|
| 80 | and "taken" logs.
|
| 81 | - `care_actions` (+ `appointments`, `activities`, `medicine_logs`) — self-care events with
|
| 82 | type enum (MEDICINE/APPOINTMENT/ACTIVITY).
|
| 83 | - `sleep_records` — daily hours of sleep + annotations.
|
| 84 | - `insights` — generated analysis rows (type/period enums, human-readable body + JSON metadata).
|
| 85 |
|
| 86 | ### Background jobs (BullMQ on Valkey) — 4 queues
|
| 87 | - **`mail`** (concurrency 10) — welcome, activation, password-reset emails via **Resend**.
|
| 88 | - **`insights`** (concurrency 5) — weekly fan-out every **Monday 03:00** cron:
|
| 89 | `MOOD_TREND`, `ENERGY_SLEEP_CORRELATION`, `TRIGGER_PATTERN` (+ `DAILY_ENERGY`,
|
| 90 | `DAILY_SLEEP`) are computed per active user and upserted into `insights`.
|
| 91 | - **`medicine-reminders`** (5) — per-regimen recurring cron jobs (created/removed by
|
| 92 | `syncMedicineReminderJobs`); sends Expo push "time to take medicine".
|
| 93 | - **`daily-reminders`** (5) — per-user daily check-in push at `dailyReminderTime`
|
| 94 | (managed by `syncDailyReminderJob`).
|
| 95 |
|
| 96 | Default job policy: 3 attempts, exponential backoff, cleanup of completed/failed jobs.
|
| 97 |
|
| 98 | ### Notifications & email
|
| 99 | - **Push:** Expo Push API (`exp.host/--/api/v2/push/send`) using stored device push tokens.
|
| 100 | - **Email:** Resend (activation code, welcome, password reset).
|
| 101 |
|
| 102 | ### Files & object storage
|
| 103 | - **Avatars:** `multer` + `multer-s3` upload to an S3-compatible store (configurable
|
| 104 | endpoint/region → MinIO/R2 style); delete via `DeleteObjectCommand`.
|
| 105 | - **PDF reports:** `@react-pdf/renderer` renders a React document from period data;
|
| 106 | endpoint `POST /reports` returns an `application/pdf` attachment.
|
| 107 |
|
| 108 | ### Root static site
|
| 109 | The API also serves `src/static/` (a Tailwind/CDN marketing landing page + link to the
|
| 110 | published thesis PDF). This is the "web/academic" face of the project.
|
| 111 |
|
| 112 | ### Tests & CI
|
| 113 | - **Vitest + supertest** against a disposable Postgres (`DATABASE_URL` pointed at an
|
| 114 | `orbit_test` DB; `TRUNCATE users CASCADE` between tests). Files: auth, users, moods,
|
| 115 | triggers, sleep, medicines, care-actions, jobs (mail, mood-trend, push).
|
| 116 | - CI (GitHub Actions) runs the suite on a Postgres 17 service container.
|
| 117 |
|
| 118 | ### Docker / deploy
|
| 119 | - Dev `Dockerfile`: node:24-alpine, installs `postgresql-client`/`valkey-cli`, runs `dev`
|
| 120 | (`prisma generate && migrate deploy && nodemon`).
|
| 121 | - `Dockerfile.production`: multi-stage build → runtime stage runs
|
| 122 | `npx prisma migrate deploy && node dist/index.js` (migrations auto-apply on boot).
|
| 123 | - CI workflow (`deploy.yml`) on `master`: test → build & push image to **GHCR** (latest +
|
| 124 | short SHA tag) → SSH into the VPS → `docker compose pull && up -d --force-recreate api`.
|
| 125 | - `ansible/provision.yml` bootstraps the VPS: packages, Docker Engine, deploy user + SSH
|
| 126 | key, `/srv/app/production`. `thesis.yml` compiles `monografia.tex` → PDF → SCP to
|
| 127 | `/srv/static/`.
|
| 128 |
|
| 129 | ---
|
| 130 |
|
| 131 | ## Frontend — `frontend/`
|
| 132 |
|
| 133 | **Stack:** Expo SDK 57 · React Native 0.86 · React 19 · TypeScript 6.0 · expo-router
|
| 134 | (file-based, typed routes) · TanStack Query v5 · Zustand · MMKV · expo-secure-store ·
|
| 135 | expo-notifications · Reanimated/gesture-handler.
|
| 136 |
|
| 137 | ### Startup / routing
|
| 138 | - `app/_layout.tsx` sets up `QueryClientProvider` → navigation `ThemeProvider` →
|
| 139 | `ToastProvider` → `AuthProvider` → Stack navigator, plus a notification observer.
|
| 140 | - `app/index.tsx` redirects: loading spinner → `/auth/login`; logged-in →
|
| 141 | `(tabs)/new`, or `/auth/activate` if the account isn't active.
|
| 142 | - `(tabs)/` — 5 tabs: **Histórico** (history), **Jornada** (insights), **Novo** (new
|
| 143 | entry flow), **Ações** (care actions), **Ajustes** (settings).
|
| 144 | - Auth screens (`auth/`): login, signup, activate, forgot/reset password.
|
| 145 | - Detail/flow screens: entry, sleep CRUD, triggers CRUD, medicine regimens, care actions
|
| 146 | (medicine / appointment / activity), notifications, report, profile.
|
| 147 |
|
| 148 | ### Data layer — TanStack Query + offline cache
|
| 149 | - `lib/queryClient.ts`: persisted client backed by **MMKV** (`react-native-mmkv`) via
|
| 150 | `createSyncStoragePersister` (throttled 1s, cache survives 24h). 5-min staleTime,
|
| 151 | 10-min GC, `retry: 1`, no refetch-on-window-focus.
|
| 152 | - Per-domain hooks in `hooks/*.queries.ts` use typed query keys, `useInfiniteQuery`
|
| 153 | pagination, and **optimistic updates** (create/delete mood entries roll back on error and
|
| 154 | invalidate insight queries after a short delay).
|
| 155 | - `hooks/useHistoryFeed.ts` merges mood/sleep/trigger/care-action entries into one feed.
|
| 156 |
|
| 157 | ### API client
|
| 158 | - `lib/api/client.ts` — single `ApiClient` (fetch) for all endpoints: auth, users, moods,
|
| 159 | sleep, triggers, insights, medicine regimens, care actions, trigger↔mood linking, and PDF
|
| 160 | report (`ArrayBuffer`). JWT stored in **expo-secure-store**; a 401 handler force-logs-out.
|
| 161 | - `lib/api/types.ts` holds shared payload/response types mirroring the backend.
|
| 162 |
|
| 163 | ### State & context
|
| 164 | - **Zustand** `stores/moodEntry.ts` — locally composes a mood entry (mood + components +
|
| 165 | intensities) before submit; `stores/careActionLink.ts` for linking.
|
| 166 | - **AuthContext** — session state, bootstrap `verifyToken`+`getMe` (with 10s timeout),
|
| 167 | login/signup/activate/logout; logout clears Query cache + MMKV.
|
| 168 | - **ToastContext** — app-wide notifications/toasts.
|
| 169 |
|
| 170 | ### Notifications (push)
|
| 171 | - `useRegisterPushToken.ts` — checks device/permission, sets Android channel, obtains the
|
| 172 | Expo push token (via EAS projectId) and registers it with the backend.
|
| 173 | - `useNotificationObserver.ts` — handles cold-launch and foreground pushes, deep-linking to
|
| 174 | the route named in `data.screen` via `router.replace/push`.
|
| 175 |
|
| 176 | ### UI
|
| 177 | - Custom kit under `components/ui/` (Button, Input, Cards, sections, sliders, datetime/
|
| 178 | time pickers, chips, empty states, toast, haptic tab). Inter font bundled via expo-font;
|
| 179 | theme tokens in `constants/theme.ts`; `misc/` has haptics, parallax, themed views.
|
| 180 | - Shared UI constants reflect the backend enums: `moods.ts`, `mood-components.ts`,
|
| 181 | `trigger-categories.ts`, `care-action-categories.ts`.
|
| 182 |
|
| 183 | ### Builds & updates
|
| 184 | - `eas.json` — development (dev client), preview (internal), production (auto-increment).
|
| 185 | `app.json` — package `com.eletrotupi.orbit`, `google-services.json` for push/FCM,
|
| 186 | `newArchEnabled: true`, expo-updates configured (OTA over `u.expo.dev`), typed routes +
|
| 187 | React Compiler experiments.
|
| 188 |
|
| 189 | ### E2E (Maestro on a physical Android device — no emulator)
|
| 190 | - **Tooling:** Maestro drives whatever `adb devices` sees (a real phone via USB/wireless
|
| 191 | debugging). Host needs **Java 17+** and **adb** only — no GUI, no Android Studio.
|
| 192 | - Arch: `sudo pacman -S android-tools jre-openjdk-headless`
|
| 193 | - Alpine: `sudo apk add android-tools openjdk17`
|
| 194 | - then `curl -fsSL https://get.maestro.mobile.dev | bash` and add `~/.maestro/bin` to PATH.
|
| 195 | - **Flows:** `frontend/e2e/*.yaml` (login, mood, logout — tagged `smoke`, share
|
| 196 | `helpers/_login.yaml`); config `frontend/.maestro/config.yaml` (appId + env defaults).
|
| 197 | UI `Button`/`Input`/`TextArea` and `MoodSelector` derive stable `testID`s from their
|
| 198 | label/title (`button-entrar`, `input-email`, `mood-great`, ...), so flows mostly use ids.
|
| 199 | - **Backend under test:** `docker-compose.e2e.yml` adds an `api-e2e` service on host port
|
| 200 | **3001**, pointed at an isolated `orbit_e2e` database. On boot it runs `prisma migrate
|
| 201 | deploy`, then `api/scripts/seed-e2e.mjs` (TRUNCATEs `users` and inserts a known active
|
| 202 | user `e2e@example.com` / `e2e-password`), then the API. Activation-by-email is deliberately
|
| 203 | skipped — the seeded user is already `active`.
|
| 204 | - **Run** (from `frontend/`):
|
| 205 | - `npm run test:e2e:all` → boots the stack, seeds, preps the device, runs the flows.
|
| 206 | - `npm run test:e2e:up` / `test:e2e:down` / `test:e2e` — the individual steps
|
| 207 | (`frontend/scripts/e2e.sh up|down|test|all`).
|
| 208 | - `npm run test:e2e -- print-env`-style: `bash scripts/e2e.sh print-env` prints the
|
| 209 | `EXPO_PUBLIC_*` vars to export before `expo start`.
|
| 210 | - **Device connectivity:** the phone must reach this machine on the LAN:
|
| 211 | - API → `EXPO_PUBLIC_API_BASE_URL=http://<lan-ip>:3001` (bundler-time env);
|
| 212 | - Metro → flows deep-link the app via `exp://<lan-ip>:8081` (Expo Go / dev client).
|
| 213 | `HOST_IP` is auto-detected by the script and can be overridden via env.
|
| 214 | - **Expo Go runtime (SDK 57):** the Play-Store Expo Go only ships SDK 54, so on a store
|
| 215 | installation the app won't load after the upgrade. Install an SDK 57 Expo Go APK from
|
| 216 | `expo start`/`eas go` on the test device (or match the runtime), otherwise Maestro flows
|
| 217 | will fail at launch.
|
| 218 | - **Notes:** each run does `adb shell pm clear` on the app (fresh SecureStore/MMKV),
|
| 219 | pre-grants `POST_NOTIFICATIONS` and disables animations for deterministic runs. For a
|
| 220 | dev-client build the appId is `com.eletrotupi.orbit` — change it in `.maestro/config.yaml`
|
| 221 | and `APP_ID` when running the script.
|
| 222 |
|
| 223 | ---
|
| 224 |
|
| 225 | ## How it works end-to-end
|
| 226 |
|
| 227 | 1. **Onboarding** — user signs up with name/email/password → backend creates the account,
|
| 228 | stores a bcrypt hash, enqueues a welcome email + a 6-digit **activation code** (valid
|
| 229 | 5 min) → user activates via `/auth/activate` → session issued.
|
| 230 | 2. **Session** — every request carries a 7-day JWT; on app boot the stored token is
|
| 231 | verified (`/auth/verify`) and refreshed user data fetched (`/users/me`).
|
| 232 | 3. **Daily use** — user records moods (with components and intensities), sleep, triggers,
|
| 233 | medicine intakes and care actions. The app writes I/O through TanStack Query mutations
|
| 234 | with optimistic UI; everything is cached in MMKV so history renders offline and
|
| 235 | revalidates later. Entries can link triggers to moods to model cause/effect.
|
| 236 | 4. **Scheduled work** — BullMQ cron jobs on Valkey:
|
| 237 | - daily/medicine push reminders per user preference;
|
| 238 | - weekly insight fan-out (Monday 03:00) computing mood trends, energy–sleep
|
| 239 | correlation, trigger patterns; daily energy/sleep summaries.
|
| 240 | Insights land in the `insights` table and surface in the **Jornada** tab.
|
| 241 | 5. **Reporting** — the user picks a period (≤ 90 days) and gets a **PDF** generated by the
|
| 242 | API (`@react-pdf/renderer`) summarizing mood, sleep, triggers and medicines — meant to
|
| 243 | be shared with the doctor/therapist.
|
| 244 | 6. **Deploy** — push to `master` → CI tests → GHCR image → SSH deploy → container boots and
|
| 245 | applies Prisma migrations before listening on :3000.
|
| 246 |
|
| 247 | ---
|
| 248 |
|
| 249 | ## Core architecture decisions & patterns
|
| 250 |
|
| 251 | ### Backend layering — Controller / Service (light repositories)
|
| 252 | - Routes are thin. **Controllers** parse + validate (`zod`) and shape HTTP responses;
|
| 253 | **Services** hold business logic and persist directly through the Prisma client
|
| 254 | (`lib/prisma.ts`). There is **no explicit Repository/DAO layer** — the Prisma client is
|
| 255 | the single data-access gateway, made pluggable via driver adapters (`PrismaPg` for
|
| 256 | Postgres, `better-sqlite3` adapter already in deps for the future offline build).
|
| 257 | - This is a **Service/Command-style** design: operations are plain async functions that
|
| 258 | take inputs and return domain results; there's no command bus or unit-of-work wrapper.
|
| 259 | Multi-step flows (e.g. signup → create user + store activation code + enqueue welcome
|
| 260 | email) are composed at the controller/service boundary.
|
| 261 | - **Domain errors** (`lib/errors/base.ts`) are raised inside services and mapped to HTTP
|
| 262 | by the single `errorHandler` middleware (incl. Prisma `P2025`→404 / `P2002`→409), so
|
| 263 | services never leak framework concerns.
|
| 264 |
|
| 265 | ### Background processing — Queue/Worker (producer–consumer) with fan-out
|
| 266 | - API code only **enqueues** jobs; dedicated BullMQ **Workers** consume each queue with
|
| 267 | per-queue concurrency (`QueueRegistry`/`WorkerRegistry`). Queues are independent, so long
|
| 268 | tasks run **truly in parallel** and can be scaled horizontally without sticky state.
|
| 269 | - **Insights** use a **fan-out** pattern: one repeatable cron job (Monday 03:00) enqueues
|
| 270 | a per-user compute job for each active user. Results are written **idempotently** via a
|
| 271 | composite unique key (`@@unique([userId, type, periodStart])`) + `prisma.insight.upsert`,
|
| 272 | so re-runs never duplicate rows.
|
| 273 | - Recurring per-user schedules (daily check-in, medicine times) are modeled as BullMQ
|
| 274 | **repeatable jobs** keyed by `jobId`; dedicated "sync" services reconcile them on any
|
| 275 | change (delete stale jobs, re-add current ones), so schedule state can't drift.
|
| 276 |
|
| 277 | ### Auth — stateless JWT
|
| 278 | - No server-side session store: a 7-day signed JWT (userId/email) is validated by the
|
| 279 | `requireAuth` middleware. Passwords are bcrypt-hashed; activation and password reset use
|
| 280 | short-lived single-use codes. The API stays horizontally scalable (no sticky sessions).
|
| 281 |
|
| 282 | ### Database — schema-lived Prisma, migrations as code
|
| 283 | - `prisma/schema.prisma` is the single source of truth; migrations are committed to the
|
| 284 | repo and applied via `prisma migrate deploy` on container boot / CI, making each deploy
|
| 285 | self-migrating and idempotent.
|
| 286 |
|
| 287 | ### Client — offline-first data access
|
| 288 | - All server state is owned by **TanStack Query hooks** with typed query keys, persisted to
|
| 289 | **MMKV** (24h) and **optimistically mutated** with rollback + controlled invalidation.
|
| 290 | The server cache is treated as local source-ish during a session, not re-fetched on every
|
| 291 | focus. JWT lives in **expo-secure-store**; a global 401 handler signs the session out.
|
| 292 |
|
| 293 | ### Typing discipline
|
| 294 | - Prisma generates the API's domain types; frontend DTO types in `lib/api/types.ts` mirror
|
| 295 | them and shared enums are duplicated in `constants/*` with `XXX: keep in sync` notes
|
| 296 | (no OpenAPI/codegen — a conscious trade-off for this size).
|
| 297 |
|
| 298 | ---
|
| 299 |
|
| 300 | ## One-paragraph summary
|
| 301 |
|
| 302 | `nexo/orbit` is a TypeScript monorepo-style academic project: an **Express 5 + Prisma 7**
|
| 303 | REST API over **PostgreSQL 17** with **BullMQ/Valkey** for cron-driven background work
|
| 304 | (insights, push and email reminders via Expo Push and Resend), S3-compatible object storage
|
| 305 | for avatars, and React-PDF for clinician reports; and an **Expo (React Native 0.81)**
|
| 306 | client using **expo-router** file routing, **TanStack Query persisted to MMKV** for
|
| 307 | offline-first UX, secure JWT storage, and push-notification deep linking. Everything is
|
| 308 | containerized for local dev via docker-compose and shipped to a VPS through GitHub Actions
|
| 309 | + GHCR + Ansible provisioning.
|