A habit-tracking app where a virtual pet's health is a real-time reflection of your real-life habits — skip your habits, and it can die.
Backend built solo across a school sprint — 30+ endpoints, JWT auth, cron-driven decay, XP/streak/mission systems, layered architecture end to end.
Note
This is a public mirror of a private school GitLab repo (Chas Academy, 2025–2026), pushed here so the code is browsable outside the school network. I was the sole backend developer — every endpoint, service, model, cron job, and CI/CD fix below is mine. Teammates built the React frontend UI and Figma design; DevOps ran the Kubernetes cluster. Secrets are replaced with placeholders.
A user adopts a pet (cat, dog, rabbit, or raccoon) that starts as an egg and evolves through levels as XP comes in from logged activity — walks, water, sleep, mood check-ins, gratitude entries, and 7-day mission streaks. Skip a day and a nightly cron job decays the pet's health; hit zero three times and the pet dies permanently, revivable only by spending coins on a fresh one. The technically interesting part isn't the pet skin — it's that XP, leveling, streak continuity, and health decay all live in pure, request-agnostic service functions, so the same rules apply whether they're triggered by an HTTP call or a scheduled job with no request object in sight.
The flow below is grounded in the actual code path for logging an activity — backend/src/services/activityService.ts, xpService.ts, and streakService.ts — plus the nightly decay job that runs independently of any request.
sequenceDiagram
actor User
participant API as Fastify API
participant Auth as authenticate middleware
participant Svc as activityService
participant XP as xpService (pure)
participant Streak as streakService
participant DB as MongoDB
User->>API: POST /api/activity/log { type: "walk" }
API->>Auth: verify JWT access token
Auth-->>API: userId
API->>Svc: logActivity(db, userId, body)
Svc->>DB: findAlivePetByUserId
Svc->>DB: already logged "walk" today?
alt not logged yet
Svc->>XP: applyXp(pet.xp, pet.level, 20)
XP-->>Svc: newXp, newLevel, newStage, leveledUp
Svc->>DB: save activity log + updated pet
else already logged
Svc-->>Svc: xpEarned = 0 (no double-dipping)
end
Svc->>Streak: checkAndUpdateStreak(db, userId)
Streak->>DB: lastStreakDate === yesterday?
Streak->>DB: increment or reset streak
Svc-->>API: { xpEarned, alreadyLogged, pet }
API-->>User: 200 OK
Note over API,DB: Independently, every night at 00:05 (Europe/Stockholm):<br/>a node-cron job decays health for inactive pets and applies<br/>the 3-strike death rule — no HTTP request involved.
Verified against the actual code — nothing here is aspirational.
| Area | State |
|---|---|
| Auth — register/login/logout/refresh, JWT access + httpOnly refresh cookie | ✅ |
| Activity logging — walk/water/sleep/read, steps sync, mood, gratitude, daily notes | ✅ |
| XP/level/stage system — pure functions, 18 levels, 7 evolution stages | ✅ |
| Streak tracking — idempotent per day, breaks on a missed day | ✅ |
| Missions — 7-day streak cycles, triple reward on day 7, max 5 active | ✅ |
Pet health decay + 3-strike death, cron job (node-cron, 00:05 Europe/Stockholm) |
✅ |
| Shop — buy/equip/unequip cosmetic accessories with coins | ✅ |
| Friends — add/accept/remove, XP leaderboard | ✅ |
| Notifications, achievements, weekly summary cron job | ✅ |
| HUD endpoint — aggregates pet/today/missions/streak/coins in one call | ✅ |
| Env validation (Zod, fail-fast), rate limiting, CORS | ✅ |
| React frontend (Vite) — all screens listed in structure | ✅ |
| Automated test coverage | 🔨 2 unit test files (validate.test.ts, authSchemas.test.ts) — not a full suite |
backend/docker-compose.yaml — all three services under services: |
✅ |
CI (.gitlab-ci.yml) |
✅ typecheck on every push; Docker build + push on develop/main |
Requires Bun and a MongoDB instance (Atlas or local).
git clone https://github.com/devinder-dev/mitt-djur.git
cd mitt-djur/backend
cp .env.example src/.env # fill in MONGODB_URI + two 32-char JWT secrets
bun install
bun run dev # http://localhost:3000 — watch modecurl localhost:3000/health
# {"status":"ok","timestamp":"...","environment":"development"}cd frontend
bun install
echo "VITE_API_URL=http://localhost:3000/api" > .env
bun run dev # http://localhost:5173The XP / level / stage system — pure functions, no I/O (click)
xpService.ts is deliberately kept free of database or HTTP concerns — it's just math, which is what lets every XP-awarding feature (steps, activities, gratitude, mood, missions) call the same applyXp() function and get identical, testable behavior:
export const applyXp = (currentXp: number, currentLevel: number, xpToAdd: number) => {
const newXp = currentXp + xpToAdd;
const newLevel = calculateLevel(newXp); // walks 18 XP thresholds, 0 → 8500
const newStage = calculateStage(newLevel); // maps level to egg → chick → ... → dragon
const leveledUp = newLevel > currentLevel;
return { newXp, newLevel, newStage, leveledUp };
};Activity XP is anti-cheese by construction: logActivity checks whether today's log already contains that activity type before awarding XP, so spamming POST /api/activity/log for the same activity on the same day earns nothing after the first call. Mood check-ins go further and reject the second call outright (409 Conflict) rather than silently no-opping.
Streaks and the nightly decay job (click)
checkAndUpdateStreak is idempotent per calendar day: it compares lastStreakDate against today and yesterday to decide whether to increment, reset to 1, or do nothing if already counted today. It's called from every activity endpoint (log, steps, mood, gratitude, notes), so any single daily action keeps the streak alive.
Health decay runs on a completely separate path — node-cron at 5 0 * * * (00:05 Europe/Stockholm), operating in bulk MongoDB writes with no per-pet loop:
- Pets active today → mood set to happy
- Inactive pets, health won't hit zero → decay by 20
- Inactive pets hitting zero (1st/2nd time) → reset to 1 health,
healthHitsZeroincremented, pet "warned" - Inactive pets hitting zero a 3rd time → pet dies (
status: 'dead')
Reviving requires the current pet to be dead — the new pet starts back at level 0, egg stage, full health.
Security controls actually implemented (click)
| Control | Implementation |
|---|---|
| Password hashing | bcrypt, 10 salt rounds |
| Access tokens | Signed JWT, kept client-side in memory (not localStorage) |
| Refresh tokens | httpOnly cookie; hash (bcrypt) stored server-side, not the raw token |
| Env validation | Zod schema in config/env.ts — app exits immediately if a required var is missing or a JWT secret is under 32 chars |
| Input validation | Zod on every route body |
| Rate limiting | @fastify/rate-limit, applied globally |
| CORS | Locked to FRONTEND_URL |
| XSS | safeSrc() sanitizer in the frontend blocks non-relative/non-https image URLs |
| NoSQL injection | Email regex validation added to the giveCoins.ts admin script after a review finding |
| Ownership checks | completeMission rejects (403) if the mission's userId doesn't match the caller |
Repository layout & tech stack (click)
Backend follows a strict layered architecture — each layer only calls the one below it:
Route → Controller → Service → Repository → Model
- Route — registers the URL + middleware, calls the controller
- Controller — validates input (Zod), calls the service, sends the HTTP response
- Service — business logic only, no MongoDB access
- Repository — all MongoDB queries, no business logic
- Model — TypeScript interfaces for every document shape
backend/
├── src/
│ ├── config/ env.ts (Zod-validated), db.ts
│ ├── controllers/ one file per endpoint
│ ├── services/ activity, xp, streak, pet, mission, shop,
│ │ friend, notification, achievement, auth, hud
│ ├── repositories/ MongoDB queries only
│ ├── models/ TypeScript document interfaces
│ ├── routes/ Fastify route registration per domain
│ ├── middleware/ authenticate.ts (JWT verification)
│ ├── plugins/ cors, rateLimit, mongodb, jwt, errorHandler
│ ├── jobs/ healthDecay.ts, weeklySummary.ts (node-cron)
│ ├── schemas/ Zod request schemas
│ └── data/ missions.json, shopItems.json
frontend/
├── src/
│ ├── api/ one client file per domain (auth, pet, shop, friends…)
│ ├── components/ Home, PetScreen, Missions, Shop, Friends,
│ │ Notifications, Profile, Login/Register, PetSelect
│ └── lib/ petImage.js, useEquippedPreview.js
kubernetes/ namespace, deployment, service, ingress, secrets
.gitlab-ci.yml typecheck → Docker build/push → deploy
| Layer | Choice |
|---|---|
| Runtime | Bun |
| API | Fastify 5 |
| Language | TypeScript |
| Database | MongoDB (Atlas or local) |
| Auth | jsonwebtoken + bcrypt, @fastify/cookie for the refresh cookie |
| Validation | Zod |
| Scheduled jobs | node-cron |
| Frontend | React 19 + Vite, vanilla CSS, React Router |
| Infra | Docker multi-stage builds, nginx (SPA routing), Kubernetes, GitLab CI |
Devinder Singh — sole backend developer · Fullstack Developer (YH) student at Chas Academy, Stockholm