Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,13 @@ jobs:
key: ${{ secrets.DO_SSH_KEY }}
script: |
set -euo pipefail
export DEPLOY_ROOT="/api"
export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
echo "USER=$(whoami)"
echo "HOME=$HOME"
echo "PWD=$(pwd)"
ls -la "$HOME"
ls -la "$HOME/api"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
cd "$DEPLOY_ROOT"
git fetch origin
git reset --hard origin/master
Expand All @@ -543,7 +549,14 @@ jobs:
script: |
set -euo pipefail
T0=$(date +%s)
export DEPLOY_ROOT="/api"
export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
echo "USER=$(whoami)"
echo "HOME=$HOME"
echo "PWD=$(pwd)"
ls -la "$HOME"
ls -la "$HOME/api"
ls -la "$DEPLOY_ROOT"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
cd "$DEPLOY_ROOT"
chmod +x scripts/*.sh
# Environment already validated in previous step
Expand Down Expand Up @@ -589,7 +602,14 @@ jobs:
script: |
set -euo pipefail
T0=$(date +%s)
export DEPLOY_ROOT="/api"
export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
echo "USER=$(whoami)"
echo "HOME=$HOME"
echo "PWD=$(pwd)"
ls -la "$HOME"
ls -la "$HOME/api"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
cd "$DEPLOY_ROOT"
INFRA_DIR="$DEPLOY_ROOT/infra"
NGINX_LIVE="/etc/nginx/sites-enabled/api.conf"
ACTIVE_SLOT_FILE="/var/run/api/active-slot"
Expand Down Expand Up @@ -647,7 +667,8 @@ jobs:
key: ${{ secrets.DO_SSH_KEY }}
script: |
set -euo pipefail
export DEPLOY_ROOT="/api"
export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
cd "$DEPLOY_ROOT"
source scripts/load-env.sh
echo "=== Checking /health via VPS (API_HOSTNAME=$API_HOSTNAME) ==="
Expand Down Expand Up @@ -679,7 +700,8 @@ jobs:
key: ${{ secrets.DO_SSH_KEY }}
script: |
set -euo pipefail
export DEPLOY_ROOT="/api"
export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
cd "$DEPLOY_ROOT"
source scripts/load-env.sh
echo "=== Final health check via public endpoint (API_HOSTNAME=$API_HOSTNAME) ==="
Expand Down Expand Up @@ -771,7 +793,13 @@ jobs:
key: ${{ secrets.DO_SSH_KEY }}
script: |
set -euo pipefail
export DEPLOY_ROOT="/api"
export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
echo "USER=$(whoami)"
echo "HOME=$HOME"
echo "PWD=$(pwd)"
ls -la "$HOME"
ls -la "$HOME/api"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
cd "$DEPLOY_ROOT"
chmod +x scripts/*.sh
./scripts/rollback.sh --auto
Expand Down
2 changes: 1 addition & 1 deletion docs/OBSERVABILITY_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ Nginx references LetsEncrypt certificates at `/etc/letsencrypt/live/<API_HOSTNAM
sed \
-e "s|__BACKEND_PORT__|3001|g" \
-e "s|__API_HOSTNAME__|$API_HOSTNAME|g" \
infra/nginx/fieldtrack.conf | sudo tee /etc/nginx/sites-enabled/fieldtrack.conf
infra/nginx/api.conf | sudo tee /etc/nginx/sites-enabled/api.conf
sudo nginx -t && sudo systemctl reload nginx
```

Expand Down
4 changes: 2 additions & 2 deletions docs/ROLLBACK_QUICKREF.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

### Deploy Latest Version
```bash
cd /api
cd "$HOME/api"
./scripts/deploy-bluegreen.sh <SHA>
```

### Rollback to Previous Version
```bash
cd /api
cd "$HOME/api"
./scripts/rollback.sh
```

Expand Down
10 changes: 5 additions & 5 deletions docs/ROLLBACK_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The history is maintained using a rolling window:
Deploy the latest image from CI:

```bash
cd /api
cd "$HOME/api"
./scripts/deploy-bluegreen.sh a4f91c2
```

Expand All @@ -71,7 +71,7 @@ cd /api
Instantly restore the last working deployment:

```bash
cd /api
cd "$HOME/api"
./scripts/rollback.sh
```

Expand Down Expand Up @@ -184,7 +184,7 @@ sudo systemctl reload nginx # Reload only if valid
- name: Deploy to VPS
run: |
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /api && \
"cd \"$HOME/api\" && \
./scripts/deploy-bluegreen.sh ${{ env.SHA_SHORT }}"
```

Expand All @@ -204,7 +204,7 @@ The history maintains the last 5 deployments in chronological order (newest firs
## File Locations

```
/api/
$HOME/api/
β”œβ”€β”€ scripts/
β”‚ β”œβ”€β”€ deploy-bluegreen.sh # Blue-green deployment
β”‚ └── rollback.sh # Rollback automation
Expand All @@ -225,7 +225,7 @@ chmod +x scripts/rollback.sh

```
ERROR: No deployment history found.
File not found: /api/.deploy_history
File not found: $HOME/api/.deploy_history
```

**Solution:** Deploy at least once before attempting rollback.
Expand Down
76 changes: 38 additions & 38 deletions docs/walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ Fastify + TypeScript backend scaffold with JWT, structured logging, modular rout

| File | Action | Purpose |
|------|--------|---------|
| [jwt.ts](file:///d:/Codebase/api/apps/api/src/types/jwt.ts) | **NEW** | Zod v4 schema for JWT payload (`sub`, `role`, `organization_id`) |
| [global.d.ts](file:///d:/Codebase/api/apps/api/src/types/global.d.ts) | **MODIFIED** | Wires `JwtPayload` into Fastify types + adds `organizationId` to request |
| [auth.ts](file:///d:/Codebase/api/apps/api/src/middleware/auth.ts) | **MODIFIED** | JWT verify β†’ Zod validate β†’ attach tenant context (or 401) |
| [tenant.ts](file:///d:/Codebase/api/apps/api/src/utils/tenant.ts) | **NEW** | `enforceTenant()` β€” scopes any query to `request.organizationId` |
| [jwt.ts](file:///d:/Codebase/api/src/types/jwt.ts) | **NEW** | Zod v4 schema for JWT payload (`sub`, `role`, `organization_id`) |
| [global.d.ts](file:///d:/Codebase/api/src/types/global.d.ts) | **MODIFIED** | Wires `JwtPayload` into Fastify types + adds `organizationId` to request |
| [auth.ts](file:///d:/Codebase/api/src/middleware/auth.ts) | **MODIFIED** | JWT verify β†’ Zod validate β†’ attach tenant context (or 401) |
| [tenant.ts](file:///d:/Codebase/api/src/utils/tenant.ts) | **NEW** | `enforceTenant()` β€” scopes any query to `request.organizationId` |

### How Tenant Enforcement Works

Expand Down Expand Up @@ -71,14 +71,14 @@ flowchart LR

| File | Layer | Purpose |
|------|-------|---------|
| [attendance.schema.ts](file:///d:/Codebase/api/apps/api/src/modules/attendance/attendance.schema.ts) | Types | DB row type, Zod pagination schema, response interfaces |
| [attendance.repository.ts](file:///d:/Codebase/api/apps/api/src/modules/attendance/attendance.repository.ts) | Repository | Supabase queries β€” all scoped via `enforceTenant()` |
| [attendance.service.ts](file:///d:/Codebase/api/apps/api/src/modules/attendance/attendance.service.ts) | Service | Business rules: no duplicate check-in, no check-out without open session |
| [attendance.controller.ts](file:///d:/Codebase/api/apps/api/src/modules/attendance/attendance.controller.ts) | Controller | Extract request data, call service, return `{ success, data }` |
| [attendance.routes.ts](file:///d:/Codebase/api/apps/api/src/modules/attendance/attendance.routes.ts) | Routes | 4 endpoints with auth middleware, ADMIN guard on org-sessions |
| [supabase.ts](file:///d:/Codebase/api/apps/api/src/config/supabase.ts) | Config | Supabase client singleton (service role key) |
| [role-guard.ts](file:///d:/Codebase/api/apps/api/src/middleware/role-guard.ts) | Middleware | Reusable `requireRole()` factory β€” 403 on role mismatch |
| [errors.ts](file:///d:/Codebase/api/apps/api/src/utils/errors.ts) | Utils | Added `ForbiddenError` (403) |
| [attendance.schema.ts](file:///d:/Codebase/api/src/modules/attendance/attendance.schema.ts) | Types | DB row type, Zod pagination schema, response interfaces |
| [attendance.repository.ts](file:///d:/Codebase/api/src/modules/attendance/attendance.repository.ts) | Repository | Supabase queries β€” all scoped via `enforceTenant()` |
| [attendance.service.ts](file:///d:/Codebase/api/src/modules/attendance/attendance.service.ts) | Service | Business rules: no duplicate check-in, no check-out without open session |
| [attendance.controller.ts](file:///d:/Codebase/api/src/modules/attendance/attendance.controller.ts) | Controller | Extract request data, call service, return `{ success, data }` |
| [attendance.routes.ts](file:///d:/Codebase/api/src/modules/attendance/attendance.routes.ts) | Routes | 4 endpoints with auth middleware, ADMIN guard on org-sessions |
| [supabase.ts](file:///d:/Codebase/api/src/config/supabase.ts) | Config | Supabase client singleton (service role key) |
| [role-guard.ts](file:///d:/Codebase/api/src/middleware/role-guard.ts) | Middleware | Reusable `requireRole()` factory β€” 403 on role mismatch |
| [errors.ts](file:///d:/Codebase/api/src/utils/errors.ts) | Utils | Added `ForbiddenError` (403) |

### Endpoints

Expand Down Expand Up @@ -132,11 +132,11 @@ curl "http://localhost:3000/attendance/org-sessions?page=1&limit=20" \

| File | Layer | Purpose |
|------|-------|---------|
| [locations.schema.ts](file:///d:/Codebase/api/apps/api/src/modules/locations/locations.schema.ts) | Types | DB row type, Zod schema (`latitude`, `longitude`, `accuracy`, `recorded_at`), response interfaces |
| [locations.repository.ts](file:///d:/Codebase/api/apps/api/src/modules/locations/locations.repository.ts) | Repository | Supabase `createLocation` and `findLocationsBySession`, scoped via `enforceTenant()` |
| [locations.service.ts](file:///d:/Codebase/api/apps/api/src/modules/locations/locations.service.ts) | Service | Business rules: verify open attendance session before insertion |
| [locations.controller.ts](file:///d:/Codebase/api/apps/api/src/modules/locations/locations.controller.ts) | Controller | Extract request data, Zod payload validation, delegate to service, format responses |
| [locations.routes.ts](file:///d:/Codebase/api/apps/api/src/modules/locations/locations.routes.ts) | Routes | 2 endpoints, both restricted to `EMPLOYEE` via role guard |
| [locations.schema.ts](file:///d:/Codebase/api/src/modules/locations/locations.schema.ts) | Types | DB row type, Zod schema (`latitude`, `longitude`, `accuracy`, `recorded_at`), response interfaces |
| [locations.repository.ts](file:///d:/Codebase/api/src/modules/locations/locations.repository.ts) | Repository | Supabase `createLocation` and `findLocationsBySession`, scoped via `enforceTenant()` |
| [locations.service.ts](file:///d:/Codebase/api/src/modules/locations/locations.service.ts) | Service | Business rules: verify open attendance session before insertion |
| [locations.controller.ts](file:///d:/Codebase/api/src/modules/locations/locations.controller.ts) | Controller | Extract request data, Zod payload validation, delegate to service, format responses |
| [locations.routes.ts](file:///d:/Codebase/api/src/modules/locations/locations.routes.ts) | Routes | 2 endpoints, both restricted to `EMPLOYEE` via role guard |

### Endpoints

Expand Down Expand Up @@ -1308,7 +1308,7 @@ When `GITHUB_SHA` is absent (local dev, manual deploy), the value is `"manual"`.
|------|--------|
| `src/server.ts` | Added `SIGTERM`/`SIGINT` graceful shutdown handlers; added boot log marker with `GITHUB_SHA` |
| `src/workers/distance.worker.ts` | Added `workerStarted` flag; `startDistanceWorker` returns `Worker \| null` and is idempotent |
| `apps/api/Dockerfile` | Added `apk add curl`; added `HEALTHCHECK` directive |
| `Dockerfile` | Added `apk add curl`; added `HEALTHCHECK` directive |

---

Expand Down Expand Up @@ -1589,15 +1589,15 @@ Phase 16 performed a clean database reset, replacing all `TEXT` columns that car
| File | Action | Purpose |
|------|--------|---------|
| `supabase/migrations/20260309000000_phase16_schema.sql` | **NEW** | Full schema DDL with ENUM types, all 7 tables, indexes, RLS policies |
| `apps/api/src/types/database.ts` | **REGENERATED** | Supabase-generated TypeScript types (removed from `.gitignore` so Docker can build) |
| `apps/api/src/types/db.ts` | **NEW** | Human-readable type aliases β€” `AttendanceSession`, `Expense`, `GpsLocation`, etc. |
| `src/types/database.ts` | **REGENERATED** | Supabase-generated TypeScript types (removed from `.gitignore` so Docker can build) |
| `src/types/db.ts` | **NEW** | Human-readable type aliases β€” `AttendanceSession`, `Expense`, `GpsLocation`, etc. |
| Attendance / expenses / locations / analytics schema files | **UPDATED** | Import row types from `db.ts` instead of long `Database["public"]["Tables"][...]["Row"]` paths |

### Migration Steps

```
supabase db reset --linked --yes
supabase gen types typescript --linked > apps/api/src/types/database.ts
supabase gen types typescript --linked > src/types/database.ts
```

---
Expand Down Expand Up @@ -1794,7 +1794,7 @@ Phase 13 moved FieldTrack 2.0 from a locally-runnable service to a fully operati

### 13.1 β€” VPS Setup Script

**File:** `apps/api/scripts/vps-setup.sh`
**File:** `scripts/vps-setup.sh`

A single idempotent script provisions a fresh Ubuntu VPS from zero to production-ready:

Expand All @@ -1810,7 +1810,7 @@ A single idempotent script provisions a fresh Ubuntu VPS from zero to production

### 13.2 β€” Nginx Reverse Proxy

**File:** `infra/nginx/fieldtrack.conf`
**File:** `infra/nginx/api.conf`

- Terminates TLS (HTTPS β†’ HTTP to backend containers)
- Upstream block points to the active blue/green container port
Expand Down Expand Up @@ -1859,12 +1859,12 @@ Dashboard is automatically loaded on container start via `infra/grafana/provisio

| File | Purpose |
|------|----------|
| `apps/api/scripts/vps-setup.sh` | Full VPS provisioning from scratch |
| `scripts/vps-setup.sh` | Full VPS provisioning from scratch |
| `infra/docker-compose.monitoring.yml` | Prometheus, Grafana, Loki, Promtail, Tempo |
| `infra/grafana/dashboards/fieldtrack.json` | Application dashboard (auto-provisioned) |
| `infra/grafana/provisioning/dashboards/dashboard.yml` | Dashboard provisioning config |
| `infra/grafana/provisioning/datasources/prometheus.yml` | Prometheus datasource provisioning |
| `infra/nginx/fieldtrack.conf` | Nginx reverse proxy and TLS termination |
| `infra/nginx/api.conf` | Nginx reverse proxy and TLS termination |
| `infra/prometheus/prometheus.yml` | Scrape config targeting backend `/metrics` |

---
Expand All @@ -1890,7 +1890,7 @@ Phase 14 connected the three pillars of observability β€” **metrics**, **logs**,

### 14.1 β€” OpenTelemetry Tracing

**File:** `apps/api/src/tracing.ts`
**File:** `src/tracing.ts`

Must be the **first import in `server.ts`** so the SDK wraps all subsequently-loaded modules.

Expand Down Expand Up @@ -1923,7 +1923,7 @@ Every HTTP request, BullMQ job, and Supabase query produces a span automatically

### 14.2 β€” Pino Log Correlation

**File:** `apps/api/src/config/logger.ts`
**File:** `src/config/logger.ts`

An `otelMixin` function injects the active trace's `trace_id`, `span_id`, and `trace_flags` into **every Pino log line** as top-level fields:

Expand All @@ -1948,7 +1948,7 @@ When no active span exists (background workers, startup), the mixin returns `{}`

### 14.3 β€” Prometheus Exemplars

**File:** `apps/api/src/plugins/prometheus.ts`
**File:** `src/plugins/prometheus.ts`

The `http_request_duration_seconds` histogram is upgraded to attach trace IDs as exemplars to each observation:

Expand All @@ -1971,13 +1971,13 @@ Infrastructure requirements enabled in `docker-compose.monitoring.yml`:

| File | Action |
|------|--------|
| `apps/api/src/tracing.ts` | **NEW** β€” OpenTelemetry SDK bootstrap; OTLP exporter to Tempo |
| `apps/api/src/server.ts` | **MODIFIED** β€” `import "./tracing.js"` as the very first import |
| `apps/api/src/config/logger.ts` | **MODIFIED** β€” `otelMixin` injects trace/span IDs into every log line |
| `apps/api/src/plugins/prometheus.ts` | **MODIFIED** β€” exemplar support on duration histogram |
| `src/tracing.ts` | **NEW** β€” OpenTelemetry SDK bootstrap; OTLP exporter to Tempo |
| `src/server.ts` | **MODIFIED** β€” `import "./tracing.js"` as the very first import |
| `src/config/logger.ts` | **MODIFIED** β€” `otelMixin` injects trace/span IDs into every log line |
| `src/plugins/prometheus.ts` | **MODIFIED** β€” exemplar support on duration histogram |
| `infra/docker-compose.monitoring.yml` | **MODIFIED** β€” Tempo OTLP ports 4317/4318; Prometheus exemplar storage |
| `infra/prometheus/prometheus.yml` | **MODIFIED** β€” OpenMetrics scrape protocol for backend jobs |
| `apps/api/src/app.ts` | **MODIFIED** β€” `onRequest` hook enriches active span with route pattern and request ID |
| `src/app.ts` | **MODIFIED** β€” `onRequest` hook enriches active span with route pattern and request ID |

---

Expand Down Expand Up @@ -2372,7 +2372,7 @@ The pipeline is split into two jobs:

### Multi-Version Rollback System

**Files:** `apps/api/scripts/deploy-bluegreen.sh`, `apps/api/scripts/rollback.sh`
**Files:** `scripts/deploy-bluegreen.sh`, `scripts/rollback.sh`

#### Deployment History

Expand Down Expand Up @@ -2415,9 +2415,9 @@ Any SHA from `.deploy_history` (or any valid GHCR tag) can be targeted directly.
| File | Action |
|------|--------|
| `.github/workflows/deploy.yml` | **MODIFIED** β€” Split into `test` + `build-and-deploy` jobs; `npm ci`; `tsc --noEmit`; GHA cache |
| `apps/api/scripts/deploy-bluegreen.sh` | **MODIFIED** β€” Appends SHA to `.deploy_history`; maintains 5-entry window |
| `apps/api/scripts/rollback.sh` | **NEW** β€” Reads history, confirms, re-deploys previous image |
| `apps/api/.gitignore` | **MODIFIED** β€” `.deploy_history` excluded |
| `scripts/deploy-bluegreen.sh` | **MODIFIED** β€” Appends SHA to `.deploy_history`; maintains 5-entry window |
| `scripts/rollback.sh` | **NEW** β€” Reads history, confirms, re-deploys previous image |
| `.gitignore` | **MODIFIED** β€” `.deploy_history` excluded |
| `docs/ROLLBACK_SYSTEM.md` | **NEW** β€” Architecture, usage, troubleshooting guide |
| `docs/ROLLBACK_QUICKREF.md` | **NEW** β€” Fast reference card for operators |

Expand All @@ -2433,4 +2433,4 @@ Any SHA from `.deploy_history` (or any valid GHCR tag) can be targeted directly.
| Docker layer cache | `cache-from/to: type=gha` in `docker/build-push-action` |
| Rollback requires 2+ deployments | Script exits with error if history is insufficient |
| Rollback time | < 10 s (image already in GHCR) |
| SHA tag on each image | `ghcr.io/.../fieldtrack-api:<sha>` retained permanently |
| SHA tag on each image | `ghcr.io/.../fieldtrack-api:<sha>` retained permanently |
12 changes: 11 additions & 1 deletion scripts/deploy-bluegreen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ trap '_ft_trap_err "$LINENO"' ERR
_FT_STATE="INIT"
DEPLOY_LOG_FILE="${DEPLOY_LOG_FILE:-/var/log/api/deploy.log}"

# Ensure log directory exists with fallback to home directory
LOG_DIR="$(dirname "$DEPLOY_LOG_FILE")"
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
LOG_DIR="$HOME/api/logs"
DEPLOY_LOG_FILE="$LOG_DIR/deploy.log"
mkdir -p "$LOG_DIR"
fi

_ft_log() {
{ set +x; } 2>/dev/null
local log_entry
Expand Down Expand Up @@ -153,7 +161,9 @@ APP_PORT=3000
NETWORK="api_network"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}"
[ -d "$DEPLOY_ROOT" ] || { echo "❌ DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; }
REPO_DIR="$DEPLOY_ROOT"

# Slot state directory and file.
# /var/run/api/ is chosen over /tmp (world-writable, cleaned by tmpwatch)
Expand Down
Loading
Loading