diff --git a/.dockerignore b/.dockerignore index e48721f..9eec4c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,21 +1,15 @@ -# Root .dockerignore — used when Docker build context is the monorepo root +# Root .dockerignore — build context is the repo root # Version control .git .gitignore -# All node_modules (each app installs its own in Docker) +# All node_modules (Docker installs fresh) node_modules -apps/*/node_modules -packages/*/node_modules - -# Frontend — exclude all web source, but keep package.json for workspace resolution -apps/web/** -!apps/web/package.json # Build artefacts (Docker regenerates these) -apps/api/dist -packages/*/dist +dist +*.tsbuildinfo # Environment files .env @@ -34,6 +28,10 @@ docs *.md .github -# Infra and scripts not needed in image +# Infra, supabase and other repo artefacts infra supabase +codeql-db +results +apps +packages diff --git a/apps/api/.env.example b/.env.example similarity index 64% rename from apps/api/.env.example rename to .env.example index 6133452..ba45bc6 100644 --- a/apps/api/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================================= -# FieldTrack 2.0 — API Environment +# FieldTrack API — Environment # Copy to .env and fill values. Do NOT commit real secrets. # ============================================================================= @@ -21,6 +21,8 @@ CORS_ORIGIN= SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= +# JWT signing secret — for local test server only (tests/setup/test-server.ts). +# NOT validated by env.ts. NOT required in production (production uses JWKS via Supabase auth). SUPABASE_JWT_SECRET= # --- Redis --- @@ -31,7 +33,7 @@ METRICS_SCRAPE_TOKEN= # --- Observability --- TEMPO_ENDPOINT=http://tempo:4318 -SERVICE_NAME=fieldtrack-backend +SERVICE_NAME=fieldtrack-api # GITHUB_SHA= (auto-injected) # --- HTTP --- @@ -39,8 +41,16 @@ BODY_LIMIT_BYTES=1000000 REQUEST_TIMEOUT_MS=30000 # --- Workers --- +# Set to true in production (Redis must be provisioned). false in dev/CI. +WORKERS_ENABLED=false MAX_QUEUE_DEPTH=1000 MAX_POINTS_PER_SESSION=50000 MAX_SESSION_DURATION_HOURS=168 WORKER_CONCURRENCY=1 -ANALYTICS_WORKER_CONCURRENCY=5 \ No newline at end of file +ANALYTICS_WORKER_CONCURRENCY=5 +WEBHOOK_WORKER_CONCURRENCY=5 + +# --- Webhook DLQ --- +WEBHOOK_DLQ_MAX_SIZE=10000 +WEBHOOK_DLQ_RETENTION_DAYS=30 +WEBHOOK_MAX_PAYLOAD_BYTES=262144 \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 36bd78e..93109a4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -38,10 +38,8 @@ Closes # ## Testing -- [ ] `npm run typecheck -w apps/api` passes -- [ ] `npm run test -w apps/api` passes -- [ ] `npm run type-check -w apps/web` passes (if frontend changed) -- [ ] `npm run build -w apps/web` passes (if frontend changed) +- [ ] `npm run typecheck` passes +- [ ] `npm test` passes - [ ] Integration tests pass locally - [ ] Manually tested the changed flows end-to-end - [ ] No special deployment steps required diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9c6e68c..36ebca9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,23 +38,15 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: '**/package-lock.json' + cache-dependency-path: package-lock.json - # ✅ Install ALL workspace deps + # ✅ Install ALL dependencies - name: Install dependencies run: npm ci - # ✅ Build shared packages first (important for TS resolution) - - name: Build shared packages - run: npm run build -w packages/types || true - # ✅ Build API (critical for CodeQL flow analysis) - name: Build API - run: npm run build -w apps/api || true - - # (optional but useful) - - name: Build Web - run: npm run build -w apps/web || true + run: npm run build || true # ✅ Initialize CodeQL AFTER dependencies - name: Initialize CodeQL diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99ebb3c..837e621 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ # Parallel stages: # validate ─┐ # test-api ├─► build-scan-push ─► deploy ─► sync-infra ─► health-and-smoke -# build-web ┘ │ +# ┘ │ # rollback ◄────────────┘ (on failure) name: Deploy to Production @@ -40,7 +40,7 @@ jobs: # JOB: validate # # Fast pre-flight: TypeScript check + dependency audit. - # Runs in parallel with test-api and build-web to maximise pipeline speed. + # Runs in parallel with test-api to maximise pipeline speed. # --------------------------------------------------------------------------- validate: name: Validate (typecheck + audit) @@ -64,9 +64,9 @@ jobs: with: node-version: '24' cache: npm - cache-dependency-path: '**/package-lock.json' + cache-dependency-path: package-lock.json - - name: Install workspace dependencies (with retry) + - name: Install dependencies (with retry) run: | echo "::group::npm ci" for attempt in 1 2 3; do @@ -77,21 +77,27 @@ jobs: done echo "::endgroup::" - - name: Build shared types - run: npm run build -w packages/types - - name: Dependency vulnerability scan run: npm audit --omit=dev --audit-level=high - - name: TypeScript check (API) - working-directory: apps/api - run: npx tsc --noEmit + - name: TypeScript check + run: npm run typecheck + + - name: Env contract guard (no direct process.env outside env.ts) + run: | + if grep -r --include="*.ts" "process\.env" src/ \ + | grep -v "src/config/env\.ts"; then + echo "❌ Direct process.env access detected outside env.ts" + echo " Use: import { env } from './config/env.js' instead" + exit 1 + fi + echo "✅ Env contract clean — no direct process.env access outside env.ts" # --------------------------------------------------------------------------- # JOB: test-api # # Full backend test suite — unit tests then integration tests. - # Runs in parallel with validate and build-web. + # Runs in parallel with validate. # --------------------------------------------------------------------------- test-api: name: API Tests (unit + integration) @@ -110,9 +116,9 @@ jobs: with: node-version: '24' cache: npm - cache-dependency-path: '**/package-lock.json' + cache-dependency-path: package-lock.json - - name: Install workspace dependencies (with retry) + - name: Install dependencies (with retry) run: | echo "::group::npm ci" for attempt in 1 2 3; do @@ -123,73 +129,8 @@ jobs: done echo "::endgroup::" - - name: Build shared types - run: npm run build -w packages/types - - - name: Unit tests - working-directory: apps/api - run: npx vitest run tests/unit/ - - - name: Integration tests - working-directory: apps/api - run: npx vitest run tests/integration/ - - # --------------------------------------------------------------------------- - # JOB: build-web - # - # Full frontend validation and production build. - # Runs in parallel with validate and test-api. - # --------------------------------------------------------------------------- - build-web: - name: Frontend Build (typecheck + lint + build) - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - NEXT_PUBLIC_API_BASE_URL: /api/proxy - NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-build-placeholder-anon-key - NEXT_PUBLIC_MAPBOX_TOKEN: pk.ci-build-placeholder - steps: - - name: Verify NEXT_PUBLIC_API_BASE_URL is set - run: | - if [ -z "$NEXT_PUBLIC_API_BASE_URL" ]; then - echo "::error::NEXT_PUBLIC_API_BASE_URL is not set. Add it to the job env block." - exit 1 - fi - echo "NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}" - - - name: Checkout - uses: actions/checkout@v5 - - - name: Setup Node.js 24 - uses: actions/setup-node@v5 - with: - node-version: '24' - cache: npm - cache-dependency-path: '**/package-lock.json' - - - name: Install workspace dependencies (with retry) - run: | - echo "::group::npm ci" - for attempt in 1 2 3; do - npm ci && break - [ $attempt -eq 3 ] && { echo "::error::npm ci failed after 3 attempts"; exit 1; } - echo "Attempt $attempt failed — retrying in 15s..." - sleep 15 - done - echo "::endgroup::" - - - name: Build shared types - run: npm run build -w packages/types - - - name: TypeScript check (web) - run: npm run typecheck -w apps/web - - - name: ESLint (web) - run: npm run lint -w apps/web - - - name: Next.js production build - run: npm run build -w apps/web + - name: Run all tests + run: npm test # --------------------------------------------------------------------------- # JOB: build-scan-push @@ -211,7 +152,7 @@ jobs: build-scan-push: name: Build, Scan & Push Docker Image runs-on: ubuntu-latest - needs: [validate, test-api, build-web] + needs: [validate, test-api] timeout-minutes: 25 permissions: contents: read @@ -245,7 +186,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./apps/api/Dockerfile + file: ./Dockerfile target: production build-args: | NODE_ENV=production @@ -254,7 +195,7 @@ jobs: load: true pull: true tags: | - fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + fieldtrack-api:${{ steps.meta.outputs.sha_short }} cache-from: type=gha,scope=production cache-to: type=gha,mode=max,scope=production @@ -262,7 +203,7 @@ jobs: # tls.createSecureContext() fails if libssl linkage is broken, proving runtime health. - name: Verify Node.js runtime (TLS operational check) run: | - IMAGE_NAME="fieldtrack-backend:${{ steps.meta.outputs.sha_short }}" + IMAGE_NAME="fieldtrack-api:${{ steps.meta.outputs.sha_short }}" echo "Testing image: $IMAGE_NAME" docker run --rm \ --entrypoint /nodejs/bin/node \ @@ -282,7 +223,7 @@ jobs: - name: Capture image digest id: digest run: | - IMAGE_NAME="fieldtrack-backend:${{ steps.meta.outputs.sha_short }}" + IMAGE_NAME="fieldtrack-api:${{ steps.meta.outputs.sha_short }}" DIGEST=$(docker inspect "$IMAGE_NAME" --format='{{.Id}}') echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" echo "=== Build traceability ===" @@ -376,7 +317,7 @@ jobs: - name: Scan image with Trivy (HIGH/CRITICAL, ignore-unfixed) env: - IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + IMAGE_NAME: fieldtrack-api:${{ steps.meta.outputs.sha_short }} run: | SCAN_PASSED=false for i in 1 2 3; do @@ -407,7 +348,7 @@ jobs: - name: Scan for unfixed CRITICAL vulnerabilities (informational) continue-on-error: true env: - IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + IMAGE_NAME: fieldtrack-api:${{ steps.meta.outputs.sha_short }} run: | UNFIXED_COUNT=$(docker run --rm \ --network none \ @@ -436,7 +377,7 @@ jobs: - name: Generate Trivy scan results (SARIF for GitHub Security) env: - IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + IMAGE_NAME: fieldtrack-api:${{ steps.meta.outputs.sha_short }} run: | docker run --rm \ --network none \ @@ -461,7 +402,7 @@ jobs: # is exactly what lands in the registry. - name: Verify image digest unchanged before push env: - IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + IMAGE_NAME: fieldtrack-api:${{ steps.meta.outputs.sha_short }} IMAGE_DIGEST: ${{ steps.digest.outputs.digest }} run: | # docker inspect .Id returns the config digest (sha256:...) which is @@ -486,16 +427,16 @@ jobs: run: | OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') docker tag \ - fieldtrack-backend:${{ steps.meta.outputs.sha_short }} \ - ghcr.io/${OWNER}/fieldtrack-backend:${{ steps.meta.outputs.sha_short }} - docker push ghcr.io/${OWNER}/fieldtrack-backend:${{ steps.meta.outputs.sha_short }} - echo "✓ Pushed ghcr.io/${OWNER}/fieldtrack-backend:${{ steps.meta.outputs.sha_short }}" + fieldtrack-api:${{ steps.meta.outputs.sha_short }} \ + ghcr.io/${OWNER}/api:${{ steps.meta.outputs.sha_short }} + docker push ghcr.io/${OWNER}/api:${{ steps.meta.outputs.sha_short }} + echo "✓ Pushed ghcr.io/${OWNER}/api:${{ steps.meta.outputs.sha_short }}" # Use the same pinned Trivy image to generate the SBOM — no additional # tool dependency, no unpinned action, same supply-chain guarantees. - name: Generate SBOM (CycloneDX) env: - IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + IMAGE_NAME: fieldtrack-api:${{ steps.meta.outputs.sha_short }} run: | docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ @@ -513,7 +454,7 @@ jobs: - name: Save build provenance env: - IMAGE_NAME: fieldtrack-backend:${{ steps.meta.outputs.sha_short }} + IMAGE_NAME: fieldtrack-api:${{ steps.meta.outputs.sha_short }} IMAGE_DIGEST: ${{ steps.digest.outputs.digest }} run: | echo "commit=${{ github.sha }}" > provenance.txt @@ -541,11 +482,11 @@ jobs: echo "| Field | Value |" echo "|---|---|" echo "| Commit SHA | \`${{ github.sha }}\` |" - echo "| Image tag | \`fieldtrack-backend:${{ steps.meta.outputs.sha_short }}\` |" + echo "| Image tag | \`fieldtrack-api:${{ steps.meta.outputs.sha_short }}\` |" echo "| Image digest | \`${IMAGE_DIGEST}\` |" echo "| SBOM components | ${SBOM_COUNT} |" echo "| Trivy gate | HIGH,CRITICAL / exit-code 1 / ignore-unfixed |" - echo "| Registry | ghcr.io/${{ github.repository_owner }}/fieldtrack-backend |" + echo "| Registry | ghcr.io/${{ github.repository_owner }}/api |" } >> "$GITHUB_STEP_SUMMARY" # --------------------------------------------------------------------------- @@ -584,13 +525,13 @@ jobs: key: ${{ secrets.DO_SSH_KEY }} script: | set -euo pipefail - export DEPLOY_ROOT="/home/ashish/FieldTrack-2.0" + export DEPLOY_ROOT="/api" cd "$DEPLOY_ROOT" git fetch origin git reset --hard origin/master - chmod +x apps/api/scripts/*.sh + chmod +x scripts/*.sh echo "=== Pre-deploy environment validation ===" - ./apps/api/scripts/validate-env.sh --check-monitoring + ./scripts/validate-env.sh --check-monitoring echo "✓ Environment contract validated" - name: Blue-Green deploy via SSH @@ -602,11 +543,11 @@ jobs: script: | set -euo pipefail T0=$(date +%s) - export DEPLOY_ROOT="/home/ashish/FieldTrack-2.0" + export DEPLOY_ROOT="/api" cd "$DEPLOY_ROOT" - chmod +x apps/api/scripts/*.sh + chmod +x scripts/*.sh # Environment already validated in previous step - ./apps/api/scripts/deploy-bluegreen.sh "${{ needs.build-scan-push.outputs.sha_short }}" + ./scripts/deploy-bluegreen.sh "${{ needs.build-scan-push.outputs.sha_short }}" echo "✓ Deploy completed in $(($(date +%s) - T0))s" - name: Log deployment state (slot + SHA for debugging) @@ -617,7 +558,7 @@ jobs: username: ${{ secrets.DO_USER }} key: ${{ secrets.DO_SSH_KEY }} script: | - ACTIVE_SLOT=$(cat /var/run/fieldtrack/active-slot 2>/dev/null || echo "unknown") + ACTIVE_SLOT=$(cat /var/run/api/active-slot 2>/dev/null || echo "unknown") DEPLOY_STATUS="UNKNOWN" # Check if health endpoint is responding (good sign of successful deploy) @@ -648,33 +589,33 @@ jobs: script: | set -euo pipefail T0=$(date +%s) - export DEPLOY_ROOT="/home/ashish/FieldTrack-2.0" + export DEPLOY_ROOT="/api" INFRA_DIR="$DEPLOY_ROOT/infra" - NGINX_LIVE="/etc/nginx/sites-enabled/fieldtrack.conf" - ACTIVE_SLOT_FILE="/var/run/fieldtrack/active-slot" + NGINX_LIVE="/etc/nginx/sites-enabled/api.conf" + ACTIVE_SLOT_FILE="/var/run/api/active-slot" ACTIVE_SLOT=$(cat "$ACTIVE_SLOT_FILE" 2>/dev/null || echo "blue") if [ "$ACTIVE_SLOT" = "green" ]; then BACKEND_PORT=3002; else BACKEND_PORT=3001; fi - # Load env from apps/api/.env — exports DEPLOY_ROOT, API_HOSTNAME, and all + # Load env from .env — exports DEPLOY_ROOT, API_HOSTNAME, and all # app variables. DEPLOY_ROOT is already exported above; load-env.sh uses it. - source "$DEPLOY_ROOT/apps/api/scripts/load-env.sh" + source "$DEPLOY_ROOT/scripts/load-env.sh" echo "✓ API_HOSTNAME: $API_HOSTNAME" echo "=== Syncing Nginx (slot: $ACTIVE_SLOT, port: $BACKEND_PORT) ===" - sudo cp "$NGINX_LIVE" /tmp/fieldtrack.conf.bak 2>/dev/null || true + sudo cp "$NGINX_LIVE" /tmp/api.conf.bak 2>/dev/null || true NGINX_TMP=$(mktemp /tmp/fieldtrack-nginx.XXXXXX.conf) sed \ -e "s|__BACKEND_PORT__|$BACKEND_PORT|g" \ -e "s|__API_HOSTNAME__|$API_HOSTNAME|g" \ - "$INFRA_DIR/nginx/fieldtrack.conf" > "$NGINX_TMP" + "$INFRA_DIR/nginx/api.conf" > "$NGINX_TMP" sudo cp "$NGINX_TMP" "$NGINX_LIVE" rm -f "$NGINX_TMP" if ! sudo nginx -t 2>&1; then echo "Nginx test failed — restoring backup..." - sudo cp /tmp/fieldtrack.conf.bak "$NGINX_LIVE" + sudo cp /tmp/api.conf.bak "$NGINX_LIVE" exit 1 fi sudo systemctl reload nginx @@ -706,9 +647,9 @@ jobs: key: ${{ secrets.DO_SSH_KEY }} script: | set -euo pipefail - export DEPLOY_ROOT="/home/ashish/FieldTrack-2.0" + export DEPLOY_ROOT="/api" cd "$DEPLOY_ROOT" - source apps/api/scripts/load-env.sh + source scripts/load-env.sh echo "=== Checking /health via VPS (API_HOSTNAME=$API_HOSTNAME) ===" for i in $(seq 1 30); do echo "---- Attempt $i ----" @@ -738,9 +679,9 @@ jobs: key: ${{ secrets.DO_SSH_KEY }} script: | set -euo pipefail - export DEPLOY_ROOT="/home/ashish/FieldTrack-2.0" + export DEPLOY_ROOT="/api" cd "$DEPLOY_ROOT" - source apps/api/scripts/load-env.sh + source scripts/load-env.sh echo "=== Final health check via public endpoint (API_HOSTNAME=$API_HOSTNAME) ===" for i in $(seq 1 10); do echo "---- Attempt $i ----" @@ -772,8 +713,8 @@ jobs: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} run: | - chmod +x apps/api/scripts/smoke-test.sh - ./apps/api/scripts/smoke-test.sh + chmod +x scripts/smoke-test.sh + ./scripts/smoke-test.sh - name: Upload smoke test report if: always() @@ -830,11 +771,11 @@ jobs: key: ${{ secrets.DO_SSH_KEY }} script: | set -euo pipefail - export DEPLOY_ROOT="/home/ashish/FieldTrack-2.0" + export DEPLOY_ROOT="/api" cd "$DEPLOY_ROOT" - chmod +x apps/api/scripts/*.sh - ./apps/api/scripts/rollback.sh --auto + chmod +x scripts/*.sh + ./scripts/rollback.sh --auto # Log final state - ACTIVE_SLOT=$(cat /var/run/fieldtrack/active-slot 2>/dev/null || echo "unknown") + ACTIVE_SLOT=$(cat /var/run/api/active-slot 2>/dev/null || echo "unknown") echo "ROLLBACK_COMPLETE | ACTIVE_SLOT=$ACTIVE_SLOT | SHA=${{ github.sha }}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7c60a53..c6b1fd9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,8 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - backend: ${{ steps.filter.outputs.backend }} - frontend: ${{ steps.filter.outputs.frontend }} + api: ${{ steps.filter.outputs.api }} infra: ${{ steps.filter.outputs.infra }} steps: - uses: actions/checkout@v5 @@ -29,23 +28,49 @@ jobs: uses: dorny/paths-filter@v3 with: filters: | - backend: - - 'apps/api/**' - - 'packages/types/**' - frontend: - - 'apps/web/**' - - 'packages/types/**' + api: + - 'src/**' + - 'tests/**' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - 'vitest.config.ts' infra: - 'infra/**' - '.github/workflows/**' - backend-ci: - name: Backend CI + api-ci: + name: API CI runs-on: ubuntu-latest needs: detect-changes timeout-minutes: 15 if: always() env: + # ── CI non-secret defaults ───────────────────────────────────────────────── + CONFIG_VERSION: "1" + APP_ENV: ci + NODE_ENV: production + PORT: "3000" + APP_BASE_URL: http://localhost:3000 + API_BASE_URL: http://localhost:3000 + FRONTEND_BASE_URL: http://localhost:3000 + CORS_ORIGIN: http://localhost:3000 + REDIS_URL: redis://invalid-ci-host:6379 + WORKERS_ENABLED: "false" + METRICS_SCRAPE_TOKEN: dummy + SERVICE_NAME: fieldtrack-api-ci + BODY_LIMIT_BYTES: "1000000" + REQUEST_TIMEOUT_MS: "30000" + MAX_QUEUE_DEPTH: "1000" + MAX_POINTS_PER_SESSION: "50000" + MAX_SESSION_DURATION_HOURS: "168" + WORKER_CONCURRENCY: "1" + ANALYTICS_WORKER_CONCURRENCY: "5" + WEBHOOK_WORKER_CONCURRENCY: "5" + WEBHOOK_DLQ_MAX_SIZE: "10000" + WEBHOOK_DLQ_RETENTION_DAYS: "30" + WEBHOOK_MAX_PAYLOAD_BYTES: "262144" + # ── Supabase — GitHub Secrets only ───────────────────────────────────────── SUPABASE_URL: ${{ secrets.SUPABASE_URL_TEST }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY_TEST }} SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY_TEST }} @@ -56,49 +81,53 @@ jobs: echo "❌ Change detection did not succeed (result: ${{ needs.detect-changes.result }}) — cannot safely skip checks" exit 1 - - name: Skip if no backend changes - if: needs.detect-changes.outputs.backend != 'true' + - name: Skip if no API changes + if: needs.detect-changes.outputs.api != 'true' run: | - echo "No backend changes — skipping all backend validation" - echo "✓ Backend CI (skipped)" + echo "No API changes — skipping all API validation" + echo "✓ API CI (skipped)" exit 0 - uses: actions/checkout@v5 - if: needs.detect-changes.outputs.backend == 'true' + if: needs.detect-changes.outputs.api == 'true' - uses: actions/setup-node@v5 - if: needs.detect-changes.outputs.backend == 'true' + if: needs.detect-changes.outputs.api == 'true' with: node-version: '24' cache: npm - cache-dependency-path: '**/package-lock.json' - - - run: npm ci - if: needs.detect-changes.outputs.backend == 'true' - - run: npm run build -w packages/types - if: needs.detect-changes.outputs.backend == 'true' - - run: npx tsc --noEmit -p apps/api - if: needs.detect-changes.outputs.backend == 'true' + cache-dependency-path: package-lock.json + + - run: npm ci --include=dev + if: needs.detect-changes.outputs.api == 'true' + - name: TypeScript check + if: needs.detect-changes.outputs.api == 'true' + run: npm run typecheck + - name: Env contract guard (no direct process.env outside env.ts) + if: needs.detect-changes.outputs.api == 'true' + run: | + if grep -r --include="*.ts" "process\.env" src/ \ + | grep -v "src/config/env\.ts"; then + echo "❌ Direct process.env access detected outside env.ts" + echo " Use: import { env } from './config/env.js' instead" + exit 1 + fi + echo "✅ Env contract clean — no direct process.env access outside env.ts" - name: Dependency vulnerability scan (production deps) - if: needs.detect-changes.outputs.backend == 'true' + if: needs.detect-changes.outputs.api == 'true' run: npm audit --omit=dev --audit-level=high - - name: Unit tests - if: needs.detect-changes.outputs.backend == 'true' - working-directory: apps/api - run: npx vitest run tests/unit/ - - name: Integration tests - if: needs.detect-changes.outputs.backend == 'true' - working-directory: apps/api - run: npx vitest run tests/integration/ + - name: Tests (unit + integration) + if: needs.detect-changes.outputs.api == 'true' + run: npm test - name: Pull base images (force fresh manifest, prevent stale GHA cache) - if: needs.detect-changes.outputs.backend == 'true' + if: needs.detect-changes.outputs.api == 'true' run: | docker pull node:24.2.0-bookworm-slim docker pull gcr.io/distroless/nodejs24-debian12:nonroot - name: Build and validate container - if: needs.detect-changes.outputs.backend == 'true' + if: needs.detect-changes.outputs.api == 'true' run: | docker build \ --pull \ @@ -106,21 +135,43 @@ jobs: --build-arg CACHE_BUSTER=${{ hashFiles('**/package-lock.json') }} \ --cache-from=type=gha,scope=pr \ --cache-to=type=gha,mode=max,scope=pr \ - -t fieldtrack-backend:ci-validation \ - -f apps/api/Dockerfile \ + -t fieldtrack-api:ci-validation \ + -f Dockerfile \ . - name: Container bootstrap validation - if: needs.detect-changes.outputs.backend == 'true' + if: needs.detect-changes.outputs.api == 'true' run: | docker run -d \ - --name fieldtrack-ci-test \ + --name api-ci-test \ -p 127.0.0.1:3001:3000 \ - --env-file apps/api/.env.ci \ - -e SUPABASE_URL=${{ secrets.SUPABASE_URL_TEST }} \ - -e SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY_TEST }} \ - -e SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY_TEST }} \ - fieldtrack-backend:ci-validation + -e CONFIG_VERSION \ + -e APP_ENV \ + -e NODE_ENV \ + -e PORT \ + -e APP_BASE_URL \ + -e API_BASE_URL \ + -e FRONTEND_BASE_URL \ + -e CORS_ORIGIN \ + -e REDIS_URL \ + -e WORKERS_ENABLED \ + -e METRICS_SCRAPE_TOKEN \ + -e SERVICE_NAME \ + -e BODY_LIMIT_BYTES \ + -e REQUEST_TIMEOUT_MS \ + -e MAX_QUEUE_DEPTH \ + -e MAX_POINTS_PER_SESSION \ + -e MAX_SESSION_DURATION_HOURS \ + -e WORKER_CONCURRENCY \ + -e ANALYTICS_WORKER_CONCURRENCY \ + -e WEBHOOK_WORKER_CONCURRENCY \ + -e WEBHOOK_DLQ_MAX_SIZE \ + -e WEBHOOK_DLQ_RETENTION_DAYS \ + -e WEBHOOK_MAX_PAYLOAD_BYTES \ + -e SUPABASE_URL \ + -e SUPABASE_ANON_KEY \ + -e SUPABASE_SERVICE_ROLE_KEY \ + fieldtrack-api:ci-validation STATUS="000" for i in $(seq 1 12); do @@ -132,7 +183,7 @@ jobs: if [ "$STATUS" != "200" ]; then echo "❌ /health returned HTTP $STATUS after 24 s (expected 200)" - docker logs fieldtrack-ci-test --tail 50 + docker logs api-ci-test --tail 50 exit 1 fi @@ -141,68 +192,14 @@ jobs: ECODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:3001${ENDPOINT}" || echo "000") if [ "$ECODE" != "401" ]; then echo "❌ ${ENDPOINT} expected 401 (unauthenticated), got ${ECODE}" - docker logs fieldtrack-ci-test --tail 50 + docker logs api-ci-test --tail 50 exit 1 fi echo "✓ ${ENDPOINT} → 401 (auth guard verified)" done - docker rm -f fieldtrack-ci-test - docker rmi fieldtrack-backend:ci-validation - - frontend-ci: - name: Frontend CI - runs-on: ubuntu-latest - needs: detect-changes - timeout-minutes: 15 - if: always() - env: - NEXT_PUBLIC_API_BASE_URL: /api/proxy - NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-build-placeholder-anon-key - NEXT_PUBLIC_MAPBOX_TOKEN: pk.ci-build-placeholder - steps: - - name: Abort if change detection failed - if: needs.detect-changes.result != 'success' - run: | - echo "❌ Change detection did not succeed (result: ${{ needs.detect-changes.result }}) — cannot safely skip checks" - exit 1 - - - name: Verify NEXT_PUBLIC_API_BASE_URL is set - run: | - if [ -z "$NEXT_PUBLIC_API_BASE_URL" ]; then - echo "::error::NEXT_PUBLIC_API_BASE_URL is not set. Add it to the job env block." - exit 1 - fi - echo "NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}" - - - name: Skip if no frontend changes - if: needs.detect-changes.outputs.frontend != 'true' - run: | - echo "No frontend changes — skipping all frontend validation" - echo "✓ Frontend CI (skipped)" - exit 0 - - - uses: actions/checkout@v5 - if: needs.detect-changes.outputs.frontend == 'true' - - - uses: actions/setup-node@v5 - if: needs.detect-changes.outputs.frontend == 'true' - with: - node-version: '24' - cache: npm - cache-dependency-path: '**/package-lock.json' - - - run: npm ci - if: needs.detect-changes.outputs.frontend == 'true' - - run: npm run build -w packages/types - if: needs.detect-changes.outputs.frontend == 'true' - - run: npm run typecheck -w apps/web - if: needs.detect-changes.outputs.frontend == 'true' - - run: npm run lint -w apps/web - if: needs.detect-changes.outputs.frontend == 'true' - - run: npm run build -w apps/web - if: needs.detect-changes.outputs.frontend == 'true' + docker rm -f api-ci-test + docker rmi fieldtrack-api:ci-validation infra-ci: name: Infra CI @@ -233,7 +230,7 @@ jobs: sed \ -e 's/__BACKEND_PORT__/3001/g' \ -e 's/__API_HOSTNAME__/api.test.local/g' \ - infra/nginx/fieldtrack.conf > /tmp/nginx.conf + infra/nginx/api.conf > /tmp/nginx.conf if grep -q '__[A-Z_]*__' /tmp/nginx.conf; then echo "❌ Unreplaced placeholders" @@ -249,5 +246,5 @@ jobs: docker run --rm \ -v /tmp/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ - -v /tmp/ssl:/etc/ssl/fieldtrack:ro \ + -v /tmp/ssl:/etc/ssl/api:ro \ nginx:1.27-alpine nginx -t \ No newline at end of file diff --git a/.github/workflows/update-base-images.yml b/.github/workflows/update-base-images.yml index ec2594c..38a347f 100644 --- a/.github/workflows/update-base-images.yml +++ b/.github/workflows/update-base-images.yml @@ -46,7 +46,7 @@ jobs: - name: Update Dockerfile digests run: | - FILE="apps/api/Dockerfile" + FILE="Dockerfile" # Update node digest in Stage 1 (builder) sed -i "s|node:24.2.0-bookworm-slim@sha256:[a-f0-9]*|node:24.2.0-bookworm-slim@sha256:${{ steps.node-digest.outputs.digest }}|g" "$FILE" @@ -63,13 +63,13 @@ jobs: - name: Check if digests changed id: changes run: | - if git diff --quiet apps/api/Dockerfile; then + if git diff --quiet Dockerfile; then echo "changed=false" >> "$GITHUB_OUTPUT" echo "✓ Digests are already current" else echo "changed=true" >> "$GITHUB_OUTPUT" echo "⚠ Base image digests have updated:" - git diff apps/api/Dockerfile + git diff Dockerfile fi - name: Create Pull Request diff --git a/.gitignore b/.gitignore index f8e1077..9915558 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # ============================================ -# Centralized .gitignore for Full Stack App +# .gitignore for FieldTrack API # ============================================ # ---------------- @@ -43,21 +43,14 @@ packages/*/node_modules/ .env.production.local !.env.example !.env.monitoring.example -!.env.ci -!.env.monitoring.ci # ---------------- # Build Output # ---------------- -# Backend dist/ build/ - -# Frontend (Next.js) .next/ out/ -/.next/ -/out/ # ---------------- # Testing @@ -72,7 +65,6 @@ coverage/ # ---------------- *.tsbuildinfo tsconfig.tsbuildinfo -next-env.d.ts # ---------------- # Logs @@ -83,7 +75,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* -lerna-debug.log* # ---------------- # OS Files diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8fb3f..f5399bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,8 +145,8 @@ All significant changes to FieldTrack 2.0 are documented here by development pha ## [Phase 13] — Production Infrastructure: VPS, Nginx & Monitoring Stack — 2026 - Added `backend/scripts/vps-setup.sh` — idempotent VPS provisioning (Docker, Nginx, systemd, certbot, ufw) -- Added `infra/nginx/fieldtrack.conf` — TLS termination, HTTP→HTTPS redirect, proxy headers, WebSocket upgrade, gzip -- Added `infra/docker-compose.monitoring.yml` — Prometheus, Grafana, Loki, Promtail, Tempo on `fieldtrack_network` +- Added `infra/nginx/api.conf` — TLS termination, HTTP→HTTPS redirect, proxy headers, WebSocket upgrade, gzip +- Added `infra/docker-compose.monitoring.yml` — Prometheus, Grafana, Loki, Promtail, Tempo on `api_network` - Added `infra/grafana/dashboards/fieldtrack.json` — pre-built dashboard (HTTP rate, latency, queue depth, heap, Redis) - Added `infra/grafana/provisioning/` — auto-provisioned dashboard and Prometheus datasource - Added `infra/prometheus/alerts.yml` — alert rules for API latency, queue depth, Redis connectivity, host metrics diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ceee90..1c1a8b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to FieldTrack 2.0 +# Contributing to FieldTrack API --- @@ -7,12 +7,12 @@ **Prerequisites:** Node.js ≥ 24, npm, Redis ```bash -git clone https://github.com/fieldtrack-tech/fieldtrack-2.0.git -cd fieldtrack-2.0 +git clone https://github.com/fieldtrack-tech/api.git +cd api npm install -cp apps/api/.env.example apps/api/.env -# Edit apps/api/.env — fill in Supabase URL, keys, Redis URL, ALLOWED_ORIGINS +cp .env.example .env +# Edit .env — fill in Supabase URL, keys, Redis URL, ALLOWED_ORIGINS ``` --- @@ -81,10 +81,8 @@ chore(deps): bump @fastify/jwt to 9.1.0 3. Run the test suite and type-checker before committing: ```bash - npm run typecheck -w apps/api - npm run test -w apps/api - npm run type-check -w apps/web # if frontend changed - npm run build -w apps/web # if frontend changed + npm run typecheck + npm test ``` 4. Commit with a conventional commit message (see format above). diff --git a/apps/api/Dockerfile b/Dockerfile similarity index 59% rename from apps/api/Dockerfile rename to Dockerfile index e1c72cd..6a5cd47 100644 --- a/apps/api/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Build context: monorepo root (docker build -f apps/api/Dockerfile .) +# Build context: repo root (docker build -f Dockerfile .) # # Three-stage build: # 1. builder — compiles TypeScript (full devDependencies available) @@ -21,34 +21,22 @@ FROM node:24.2.0-bookworm-slim@sha256:1a6a7b2e2e2c80a6973f57aa8b0c6ad67a961ddbc5ef326c448e133f93564ff9 AS builder # Cache buster: force rebuild when package-lock.json changes. -# Prevents stale dependency layers from being reused on deployment. ARG CACHE_BUSTER=1 WORKDIR /workspace # Copy package manifests first for layer-cached dependency install. COPY package.json package-lock.json ./ -COPY apps/api/package.json ./apps/api/ -COPY packages/types/package.json ./packages/types/ -RUN npm ci --workspace apps/api --workspace packages/types --include-workspace-root=false - -# Compile shared types first (apps/api depends on @fieldtrack/types at runtime). -COPY packages/types/tsconfig.json ./packages/types/ -COPY packages/types/src/ ./packages/types/src/ -RUN npm run build -w packages/types +RUN npm ci # Compile API. -WORKDIR /workspace/apps/api -COPY apps/api/tsconfig.json ./ -COPY apps/api/src ./src +COPY tsconfig.json ./ +COPY src ./src RUN npm run build # ---- Stage 2: Production dependencies ------------------------------------- # Separate stage: installs --omit=dev so distroless never needs npm or a shell. -# mkdir -p guards ensure workspace subdirectories always exist for the COPY in -# stage 3, even when npm hoists all deps to the root node_modules. -# NOTE: Must use SAME base image digest as Stage 1 to ensure consistency. FROM node:24.2.0-bookworm-slim@sha256:1a6a7b2e2e2c80a6973f57aa8b0c6ad67a961ddbc5ef326c448e133f93564ff9 AS runtime-deps # Cache buster: force rebuild when package-lock.json changes. @@ -57,15 +45,8 @@ ARG CACHE_BUSTER=1 WORKDIR /workspace COPY package.json package-lock.json ./ -COPY apps/api/package.json ./apps/api/ -COPY packages/types/package.json ./packages/types/ - -RUN npm ci \ - --omit=dev \ - --workspace apps/api \ - --workspace packages/types \ - --include-workspace-root=false \ - && mkdir -p apps/api/node_modules packages/types/node_modules \ + +RUN npm ci --omit=dev \ && npm cache clean --force # ---- Stage 3: Production (distroless) ------------------------------------- @@ -84,25 +65,17 @@ WORKDIR /app ENV NODE_ENV=production -# Package manifests — required for npm workspace module resolution at runtime. -# The @fieldtrack/types symlink in node_modules points to ../../packages/types; -# Node.js resolves it to /app/packages/types which is present below. +# Package manifest — required for Node.js module resolution at runtime. COPY package.json ./ -COPY apps/api/package.json ./apps/api/ -COPY packages/types/package.json ./packages/types/ -# Production node_modules (root-hoisted + workspace-specific). -# BuildKit COPY preserves symlinks, so workspace links resolve correctly. -COPY --from=runtime-deps /workspace/node_modules ./node_modules -COPY --from=runtime-deps /workspace/apps/api/node_modules ./apps/api/node_modules -COPY --from=runtime-deps /workspace/packages/types/node_modules ./packages/types/node_modules +# Production node_modules. +COPY --from=runtime-deps /workspace/node_modules ./node_modules # Compiled application output. -COPY --from=builder /workspace/apps/api/dist ./apps/api/dist -COPY --from=builder /workspace/packages/types/dist ./packages/types/dist +COPY --from=builder /workspace/dist ./dist # Healthcheck script — uses Node built-in http (curl not available in distroless). -COPY apps/api/healthcheck.js ./healthcheck.js +COPY healthcheck.js ./healthcheck.js EXPOSE 3000 @@ -110,4 +83,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["/nodejs/bin/node", "/app/healthcheck.js"] -CMD ["apps/api/dist/server.js"] +CMD ["dist/server.js"] diff --git a/README.md b/README.md index 4acec21..6a731f6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Production-grade multi-tenant backend for real-time field workforce tracking — attendance, GPS, expense management, and admin analytics. -[![CI](https://github.com/fieldtrack-tech/fieldtrack-2.0/actions/workflows/deploy.yml/badge.svg)](https://github.com/fieldtrack-tech/fieldtrack-2.0/actions/workflows/deploy.yml) +[![CI](https://github.com/fieldtrack-tech/api/actions/workflows/deploy.yml/badge.svg)](https://github.com/fieldtrack-tech/api/actions/workflows/deploy.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D24-brightgreen)](https://nodejs.org) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org) @@ -102,7 +102,6 @@ FieldTrack 2.0 is a production-ready REST API backend for managing field workfor ```bash # 1. Install dependencies -cd backend npm install # 2. Configure environment @@ -142,20 +141,21 @@ See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for full setup instructions includi ## Project Structure +> **Note:** The web frontend is maintained in a separate repository: [fieldtrack-tech/web](https://github.com/fieldtrack-tech/web) + ``` -FieldTrack-2.0/ -├── apps/api/ # Fastify + TypeScript backend -│ ├── src/ # Application source -│ │ ├── modules/ # Domain modules (attendance · locations · expenses · analytics) -│ │ ├── plugins/ # Fastify plugins (JWT · Prometheus · security stack) -│ │ ├── workers/ # BullMQ distance calculation worker -│ │ ├── middleware/ # Auth + role guard -│ │ └── utils/ # Shared utilities (errors · response · tenant · metrics) -│ ├── tests/ # Vitest unit and integration tests -│ └── scripts/ # Blue-green deploy + rollback scripts -├── infra/ # Monitoring stack (Prometheus · Grafana · Loki · Tempo) -├── docs/ # Project documentation -└── .github/workflows/ # GitHub Actions CI/CD +api/ +├── src/ # Application source +│ ├── modules/ # Domain modules (attendance · locations · expenses · analytics) +│ ├── plugins/ # Fastify plugins (JWT · Prometheus · security stack) +│ ├── workers/ # BullMQ distance calculation worker +│ ├── middleware/ # Auth + role guard +│ └── utils/ # Shared utilities (errors · response · tenant · metrics) +├── tests/ # Vitest unit and integration tests +├── scripts/ # Blue-green deploy + rollback scripts +├── infra/ # Monitoring stack (Prometheus · Grafana · Loki · Tempo) +├── docs/ # Project documentation +└── .github/workflows/ # GitHub Actions CI/CD ``` --- diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore deleted file mode 100644 index e1eb7fb..0000000 --- a/apps/api/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -dist -.env -.git -.gitignore -*.md -.dockerignore diff --git a/apps/api/.env.ci b/apps/api/.env.ci deleted file mode 100644 index c483a67..0000000 --- a/apps/api/.env.ci +++ /dev/null @@ -1,46 +0,0 @@ -# ============================================================================= -# FieldTrack 2.0 — CI Environment -# -# Non-secret configuration for CI containers. -# Supabase credentials MUST be injected via GitHub Actions secrets: -# -e SUPABASE_URL=${{ secrets.SUPABASE_URL_TEST }} -# -e SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY_TEST }} -# -e SUPABASE_SERVICE_ROLE_KEY=${{ secrets.SUPABASE_SERVICE_ROLE_KEY_TEST }} -# -# ⚠️ CRITICAL SAFEGUARD: -# This file MUST NOT contain Supabase credentials: -# ❌ SUPABASE_URL -# ❌ SUPABASE_ANON_KEY -# ❌ SUPABASE_SERVICE_ROLE_KEY -# These ONLY come from GitHub Actions secrets.env. -# Docker load order: --env-file FIRST, then -e overrides AFTER. -# If this file contained secrets, the -e injection would be useless. -# ============================================================================= - -CONFIG_VERSION=1 -APP_ENV=ci -NODE_ENV=production -PORT=3000 - -APP_BASE_URL=http://localhost:3000 -API_BASE_URL=http://localhost:3000 -FRONTEND_BASE_URL=http://localhost:3000 - -CORS_ORIGIN=http://localhost:3000 - -# Supabase → MUST come from GitHub Secrets - -REDIS_URL=redis://invalid-ci-host:6379 -WORKERS_ENABLED=false - -METRICS_SCRAPE_TOKEN=dummy -SERVICE_NAME=fieldtrack-backend-ci - -BODY_LIMIT_BYTES=1000000 -REQUEST_TIMEOUT_MS=30000 - -MAX_QUEUE_DEPTH=1000 -MAX_POINTS_PER_SESSION=50000 -MAX_SESSION_DURATION_HOURS=168 -WORKER_CONCURRENCY=1 -ANALYTICS_WORKER_CONCURRENCY=5 diff --git a/apps/api/README.md b/apps/api/README.md deleted file mode 100644 index 192e12a..0000000 --- a/apps/api/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# FieldTrack 2.0 — Backend - -Production-ready Fastify + TypeScript backend for FieldTrack 2.0 SaaS platform. - -## Tech Stack - -- **Runtime**: Node.js 24+ -- **Language**: TypeScript 5.9 (strict mode, ESM) -- **Framework**: Fastify 5 -- **Auth**: @fastify/jwt (Supabase JWT) -- **Database**: PostgreSQL via Supabase -- **Job Queue**: BullMQ + Redis -- **Validation**: Zod 4 (`fastify-type-provider-zod`) -- **Observability**: OpenTelemetry 2.x, Prometheus, Grafana -- **Security**: @fastify/helmet, @fastify/cors, @fastify/rate-limit, @fastify/compress - -## Quick Start - -```bash -# Install dependencies -npm install - -# Copy environment template and fill in values -cp .env.example .env - -# Start development server -npm run dev -``` - -## Scripts - -| Command | Description | -| --------------- | ---------------------------------- | -| `npm run dev` | Start dev server with hot reload | -| `npm run build` | Compile TypeScript to `dist/` | -| `npm start` | Run compiled production server | -| `npm test` | Run test suite with Vitest | - -## Deployment - -### Production Deployment - -```bash -# Deploy specific version (automated via CI) -./scripts/deploy-bluegreen.sh a4f91c2 - -# Rollback to previous version -./scripts/rollback.sh - -# Deploy specific historical version -./scripts/deploy-bluegreen.sh 7b3e9f1 -``` - -See [Rollback System Documentation](../docs/ROLLBACK_SYSTEM.md) for detailed deployment and rollback procedures. - -## Project Structure - -``` -src/ -├── server.ts # Entry point -├── app.ts # Fastify app factory -├── tracing.ts # OpenTelemetry tracing setup -├── config/ # Environment & logger config -├── plugins/ # Fastify plugins -│ ├── zod.plugin.ts # Shared Zod compiler registration (single source of truth) -│ ├── openapi.plugin.ts # Swagger / OpenAPI documentation -│ ├── jwt.ts # JWT plugin -│ ├── prometheus.ts # Prometheus metrics -│ └── security/ # helmet, cors, rate-limit, abuse-logging -├── routes/ # Route modules -├── middleware/ # Auth & request middleware -├── modules/ # Business domain modules -│ ├── attendance/ -│ ├── expenses/ -│ ├── locations/ -│ └── session_summary/ -├── workers/ # BullMQ background job workers -├── types/ # TypeScript type definitions -└── utils/ # Shared utilities -``` - -## Docker - -```bash -# Build image -docker build -t fieldtrack-backend . - -# Run container -docker run -p 3000:3000 --env-file .env fieldtrack-backend -``` - -## API Endpoints - -| Method | Path | Description | Auth | -| ------ | --------- | -------------------- | -------- | -| GET | `/health` | Health check | None | - -See [API Reference](../docs/API_REFERENCE.md) for the complete endpoint list. - -## Environment Variables - -| Variable | Description | Required | -| -------------------------- | --------------------------- | -------- | -| `PORT` | Server port (default: 3000) | No | -| `NODE_ENV` | Environment mode | No | -| `SUPABASE_URL` | Supabase project URL | Yes | -| `SUPABASE_SERVICE_ROLE_KEY`| Supabase service role key | Yes | -| `SUPABASE_JWT_SECRET` | JWT signing secret | Yes | -| `REDIS_HOST` | Redis host for BullMQ | Yes | -| `REDIS_PORT` | Redis port (default: 6379) | No | -| `TEMPO_ENDPOINT` | Tempo OTLP endpoint | No | - -See `.env.example` for a complete list of environment variables. - -## Documentation - -Detailed documentation is available in the [`/docs`](../docs) directory: - -- [API Reference](../docs/API_REFERENCE.md) — all endpoints, auth requirements, request/response schemas -- [Architecture](../docs/ARCHITECTURE.md) — system design, request lifecycle, tenant isolation, key decisions -- [Walkthrough](../docs/walkthrough.md) — phase-by-phase development history and technical deep-dives -- [Rollback System](../docs/ROLLBACK_SYSTEM.md) — deployment tracking and rollback architecture -- [Rollback Quick Reference](../docs/ROLLBACK_QUICKREF.md) — fast command reference for deployment and rollback - -See [CHANGELOG.md](../CHANGELOG.md) at the repo root for a full project history. diff --git a/apps/api/docs/runbooks/cert-renewal-failure.md b/apps/api/docs/runbooks/cert-renewal-failure.md deleted file mode 100644 index c4f00de..0000000 --- a/apps/api/docs/runbooks/cert-renewal-failure.md +++ /dev/null @@ -1,314 +0,0 @@ -# Runbook: TLS Certificate Renewal Failure - -## Symptoms -- Certificate expiry alert firing -- Browser shows "Your connection is not private" -- Certbot renewal fails -- HTTPS connections rejected - -## Immediate Actions - -### 1. Check Certificate Status -```bash -# Check certificate expiry -sudo certbot certificates - -# Check specific domain -echo | openssl s_client -servername api.fieldtrack.meowsician.tech \ - -connect api.fieldtrack.meowsician.tech:443 2>/dev/null | \ - openssl x509 -noout -dates - -# Check days until expiry -echo | openssl s_client -servername api.fieldtrack.meowsician.tech \ - -connect api.fieldtrack.meowsician.tech:443 2>/dev/null | \ - openssl x509 -noout -enddate -``` - -### 2. Check Certbot Logs -```bash -# View recent renewal attempts -sudo tail -100 /var/log/letsencrypt/letsencrypt.log - -# Check for errors -sudo grep -i error /var/log/letsencrypt/letsencrypt.log | tail -20 -``` - -### 3. Verify ACME Challenge Access -```bash -# Test .well-known/acme-challenge is accessible -curl -I http://api.fieldtrack.meowsician.tech/.well-known/acme-challenge/test - -# Should return 404 (not 403 or 502) -# 403 = blocked by nginx -# 502 = nginx misconfigured -``` - -## Manual Renewal - -### Standard Renewal -```bash -# Dry run first (test without actually renewing) -sudo certbot renew --dry-run - -# If dry run succeeds, do actual renewal -sudo certbot renew - -# Reload nginx to use new certificate -sudo systemctl reload nginx -``` - -### Force Renewal (if cert not yet expired) -```bash -# Force renewal even if not due -sudo certbot renew --force-renewal - -# Reload nginx -sudo systemctl reload nginx -``` - -### Interactive Renewal (if automated fails) -```bash -# Stop nginx temporarily -sudo systemctl stop nginx - -# Run certbot standalone -sudo certbot certonly --standalone \ - -d api.fieldtrack.meowsician.tech \ - --email your-email@example.com \ - --agree-tos - -# Start nginx -sudo systemctl start nginx -``` - -## Root Cause Analysis - -### Common Failure Modes - -1. **ACME Challenge Blocked** - ```bash - # Check nginx config allows .well-known - sudo nginx -T | grep -A 5 "well-known" - - # Should have: - # location /.well-known/acme-challenge/ { - # root /var/www/certbot; - # } - - # Verify directory exists and is writable - ls -la /var/www/certbot/.well-known/acme-challenge/ - ``` - -2. **DNS Issues** - ```bash - # Verify DNS resolves correctly - dig api.fieldtrack.meowsician.tech +short - - # Should return your VPS IP - # If not, update DNS records - ``` - -3. **Rate Limiting** - ```bash - # Let's Encrypt has rate limits: - # - 5 failed validations per hour - # - 50 certificates per domain per week - - # Check if rate limited - sudo grep "rate limit" /var/log/letsencrypt/letsencrypt.log - - # If rate limited, wait and try again later - ``` - -4. **Firewall Blocking Port 80** - ```bash - # Check port 80 is open - sudo ufw status | grep 80 - - # Should show: - # 80/tcp ALLOW Anywhere - - # If blocked, allow it - sudo ufw allow 80/tcp - ``` - -5. **Certbot Service Not Running** - ```bash - # Check certbot timer - sudo systemctl status certbot.timer - - # If inactive, enable it - sudo systemctl enable certbot.timer - sudo systemctl start certbot.timer - ``` - -## Certificate Validation - -### Verify New Certificate -```bash -# Check certificate details -sudo openssl x509 -in /etc/letsencrypt/live/api.fieldtrack.meowsician.tech/fullchain.pem \ - -noout -text | grep -A 2 "Validity" - -# Verify certificate chain -sudo openssl verify -CAfile /etc/letsencrypt/live/api.fieldtrack.meowsician.tech/chain.pem \ - /etc/letsencrypt/live/api.fieldtrack.meowsician.tech/cert.pem - -# Test HTTPS connection -curl -vI https://api.fieldtrack.meowsician.tech 2>&1 | grep -i "SSL certificate" -``` - -### Browser Test -```bash -# Test from external location -curl -I https://api.fieldtrack.meowsician.tech - -# Check SSL Labs rating (optional) -# https://www.ssllabs.com/ssltest/analyze.html?d=api.fieldtrack.meowsician.tech -``` - -## Prevention - -### Automated Renewal -```bash -# Certbot should auto-renew via systemd timer -# Verify timer is active -sudo systemctl list-timers | grep certbot - -# Should show next run time -# Certbot renews certs 30 days before expiry -``` - -### Monitoring -```bash -# Add Prometheus alert for certificate expiry -# Already configured in infra/prometheus/alerts.yml -# Alert fires when cert expires in < 7 days -``` - -### Pre-Renewal Hook -```bash -# Create pre-renewal hook to stop nginx if needed -sudo nano /etc/letsencrypt/renewal-hooks/pre/stop-nginx.sh - -#!/bin/bash -# Only stop nginx if using standalone mode -# Not needed for webroot mode (default) - -# Make executable -sudo chmod +x /etc/letsencrypt/renewal-hooks/pre/stop-nginx.sh -``` - -### Post-Renewal Hook -```bash -# Create post-renewal hook to reload nginx -sudo nano /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh - -#!/bin/bash -systemctl reload nginx - -# Make executable -sudo chmod +x /etc/letsencrypt/renewal-hooks/post/reload-nginx.sh -``` - -## Emergency Procedures - -### Certificate Expired (Downtime Acceptable) -```bash -# Stop nginx -sudo systemctl stop nginx - -# Remove old certificate -sudo certbot delete --cert-name api.fieldtrack.meowsician.tech - -# Get new certificate (standalone) -sudo certbot certonly --standalone \ - -d api.fieldtrack.meowsician.tech \ - --email your-email@example.com \ - --agree-tos - -# Start nginx -sudo systemctl start nginx -``` - -### Certificate Expired (Zero Downtime Required) -```bash -# Use webroot mode (nginx stays running) -sudo certbot certonly --webroot \ - -w /var/www/certbot \ - -d api.fieldtrack.meowsician.tech \ - --email your-email@example.com \ - --agree-tos - -# Reload nginx -sudo systemctl reload nginx -``` - -## Backup Certificates - -### Export Current Certificates -```bash -# Create backup directory -mkdir -p ~/cert-backup-$(date +%Y%m%d) - -# Copy certificates -sudo cp -r /etc/letsencrypt/live/api.fieldtrack.meowsician.tech/ \ - ~/cert-backup-$(date +%Y%m%d)/ - -sudo cp -r /etc/letsencrypt/archive/api.fieldtrack.meowsician.tech/ \ - ~/cert-backup-$(date +%Y%m%d)/archive/ - -# Copy renewal config -sudo cp /etc/letsencrypt/renewal/api.fieldtrack.meowsician.tech.conf \ - ~/cert-backup-$(date +%Y%m%d)/ -``` - -### Restore Certificates -```bash -# Stop nginx -sudo systemctl stop nginx - -# Restore from backup -sudo cp -r ~/cert-backup-YYYYMMDD/api.fieldtrack.meowsician.tech/ \ - /etc/letsencrypt/live/ - -sudo cp -r ~/cert-backup-YYYYMMDD/archive/ \ - /etc/letsencrypt/archive/api.fieldtrack.meowsician.tech/ - -sudo cp ~/cert-backup-YYYYMMDD/api.fieldtrack.meowsician.tech.conf \ - /etc/letsencrypt/renewal/ - -# Start nginx -sudo systemctl start nginx -``` - -## Escalation - -If certificate renewal continues to fail: -1. Check Let's Encrypt status page: https://letsencrypt.status.io/ -2. Verify domain ownership and DNS records -3. Consider using alternative ACME client (acme.sh) -4. Contact Let's Encrypt support if rate limited -5. Consider purchasing commercial certificate as temporary solution - -## Post-Incident - -1. **Document Root Cause** - - What caused the failure? - - How was it detected? - - How long was the outage? - -2. **Update Monitoring** - - Ensure certificate expiry alert is working - - Set alert threshold to 14 days (not 7) - - Add alert for renewal failures - -3. **Improve Automation** - - Verify certbot timer is enabled - - Test renewal process in staging - - Document manual renewal procedure - -4. **Communication** - - Notify users if there was downtime - - Update status page - - Create incident report diff --git a/apps/api/docs/runbooks/container-crash.md b/apps/api/docs/runbooks/container-crash.md deleted file mode 100644 index f9fd1a8..0000000 --- a/apps/api/docs/runbooks/container-crash.md +++ /dev/null @@ -1,170 +0,0 @@ -# Runbook: Container Crash - -## Symptoms -- Backend container stops unexpectedly -- 502 Bad Gateway errors from Nginx -- Prometheus shows container down -- Health checks failing - -## Immediate Actions - -### 1. Identify Crashed Container -```bash -# Check container status -docker ps -a | grep backend - -# Get exit code and status -docker inspect backend-blue --format='{{.State.ExitCode}} {{.State.Status}}' -docker inspect backend-green --format='{{.State.ExitCode}} {{.State.Status}}' -``` - -### 2. Retrieve Crash Logs -```bash -# Get last 200 lines before crash -docker logs backend-blue --tail 200 > /tmp/crash-blue.log -docker logs backend-green --tail 200 > /tmp/crash-green.log - -# Check for OOM kills -dmesg | grep -i "out of memory" -dmesg | grep -i "killed process" -``` - -### 3. Check System Resources -```bash -# Memory usage -free -h -docker stats --no-stream - -# Disk space -df -h - -# CPU load -uptime -top -bn1 | head -20 -``` - -## Recovery Procedure - -### Quick Recovery (Restart Container) -```bash -# Restart the crashed container -docker restart backend-blue # or backend-green - -# Wait for health check -for i in {1..30}; do - curl -f http://localhost:3001/ready && break || sleep 2 -done -``` - -### Full Recovery (Redeploy) -```bash -cd /home/ashish/FieldTrack-2.0 -export DEPLOY_ROOT=/home/ashish/FieldTrack-2.0 - -# Get current deployment SHA -CURRENT_SHA=$(head -1 apps/api/.deploy_history) - -# Redeploy current version -./apps/api/scripts/deploy-bluegreen.sh "$CURRENT_SHA" -``` - -## Root Cause Analysis - -### Exit Code Meanings -- **0**: Clean shutdown (rare for crash) -- **1**: Application error (check logs) -- **137**: OOM killed (out of memory) -- **139**: Segmentation fault -- **143**: SIGTERM (manual stop) - -### Common Crash Causes - -1. **Out of Memory (Exit 137)** - ```bash - # Check memory limits - docker inspect backend-blue | grep -i memory - - # Increase memory limit if needed - # Edit docker run command in deploy script - ``` - -2. **Unhandled Exception** - - Check application logs for stack traces - - Look for "Unhandled error" messages - - Verify database connectivity - -3. **Database Connection Loss** - ```bash - # Test Supabase connectivity - curl -H "apikey: $SUPABASE_ANON_KEY" $SUPABASE_URL/rest/v1/ - - # Check connection pool exhaustion in logs - grep "connection pool" /tmp/crash-*.log - ``` - -4. **Redis Connection Loss** - ```bash - # Check Redis status - docker ps | grep redis - redis-cli -u $REDIS_URL ping - - # Check BullMQ connection errors - grep "Redis connection" /tmp/crash-*.log - ``` - -5. **Worker Queue Saturation** - ```bash - # Check queue depths - redis-cli -u $REDIS_URL llen bull:distance:wait - redis-cli -u $REDIS_URL llen bull:analytics:wait - - # If > MAX_QUEUE_DEPTH, drain queue - ``` - -## Prevention - -### Monitoring -- Set up Prometheus alerts for container down -- Monitor memory usage trends -- Track error rates before crashes - -### Configuration -- Set appropriate memory limits -- Configure restart policy: `--restart unless-stopped` -- Enable Docker healthcheck -- Set METRICS_SCRAPE_TOKEN in production - -### Code Quality -- Add error boundaries for async operations -- Implement graceful shutdown handlers -- Add circuit breakers for external services -- Log all unhandled rejections - -## Post-Incident - -1. **Preserve Evidence** - ```bash - # Save logs - cp /tmp/crash-*.log ~/incident-$(date +%Y%m%d-%H%M%S)/ - - # Save container state - docker inspect backend-blue > ~/incident-*/inspect.json - ``` - -2. **Update Monitoring** - - Add alert for specific error pattern - - Adjust thresholds if needed - - Document in incident log - -3. **Code Fix** - - Create GitHub issue with logs - - Add test case to prevent recurrence - - Deploy fix through normal CI/CD - -## Escalation - -If crashes persist after recovery: -1. Check Grafana dashboard for patterns -2. Review recent code changes -3. Consider rolling back to last stable version -4. Engage development team for debugging diff --git a/apps/api/docs/runbooks/deploy-failure.md b/apps/api/docs/runbooks/deploy-failure.md deleted file mode 100644 index 1547189..0000000 --- a/apps/api/docs/runbooks/deploy-failure.md +++ /dev/null @@ -1,102 +0,0 @@ -# Runbook: Deployment Failure - -## Symptoms -- GitHub Actions deploy job fails -- Container fails health/readiness checks -- Nginx configuration validation fails -- Blue-green switch incomplete - -## Immediate Actions - -### 1. Check Deployment Logs -```bash -# SSH to VPS -ssh $DO_USER@$DO_HOST - -# Check recent deployment logs -cd /home/ashish/FieldTrack-2.0 -tail -100 apps/api/.deploy_history - -# Check container logs -docker logs backend-blue --tail 100 -docker logs backend-green --tail 100 -``` - -### 2. Verify Container Status -```bash -# Check running containers -docker ps -a | grep backend - -# Check container health -docker inspect backend-blue | grep -A 10 Health -docker inspect backend-green | grep -A 10 Health -``` - -### 3. Check Readiness Dependencies -```bash -# Test Redis connectivity -redis-cli -u $REDIS_URL ping - -# Test database connectivity (from container) -docker exec backend-blue curl -f http://localhost:3000/ready - -# Check Supabase connectivity -curl -H "apikey: $SUPABASE_ANON_KEY" $SUPABASE_URL/rest/v1/ -``` - -## Rollback Procedure - -### Automatic Rollback -GitHub Actions automatically triggers rollback on deployment failure. - -### Manual Rollback -```bash -cd /home/ashish/FieldTrack-2.0 -chmod +x apps/api/scripts/rollback.sh -./apps/api/scripts/rollback.sh -``` - -## Root Cause Analysis - -### Common Failure Modes - -1. **Environment Variable Missing** - - Check `.env` file completeness - - Verify METRICS_SCRAPE_TOKEN in production - - Validate DEPLOY_ROOT is set - -2. **Database Migration Failure** - - Check migration logs - - Verify Supabase connection - - Rollback migration if needed - -3. **Redis Connection Failure** - - Verify Redis container running - - Check REDIS_URL format - - Test connectivity from app container - -4. **Image Pull Failure** - - Verify GitHub Container Registry access - - Check image tag exists - - Validate network connectivity - -5. **Nginx Configuration Error** - - Check template substitution - - Verify API_HOSTNAME set correctly (derived from API_BASE_URL in apps/api/.env) - - Test nginx config: `sudo nginx -t` - -## Prevention - -- Always test in staging first -- Run smoke tests before production deploy -- Monitor deployment metrics -- Keep rollback history (last 5 deployments) -- Set DEPLOY_ROOT environment variable - -## Escalation - -If rollback fails or issue persists: -1. Check monitoring dashboard: https://api.fieldtrack.meowsician.tech/monitor/ -2. Review Loki logs for errors -3. Contact DevOps team -4. Consider manual container restart as last resort diff --git a/apps/api/docs/runbooks/monitoring-failure.md b/apps/api/docs/runbooks/monitoring-failure.md deleted file mode 100644 index fc459cd..0000000 --- a/apps/api/docs/runbooks/monitoring-failure.md +++ /dev/null @@ -1,262 +0,0 @@ -# Runbook: Monitoring Stack Failure - -## Symptoms -- Grafana dashboard unreachable -- Prometheus not scraping metrics -- Loki logs not appearing -- Alerts not firing - -## Immediate Actions - -### 1. Check Monitoring Containers -```bash -# Check all monitoring containers -docker ps | grep -E "prometheus|grafana|loki|promtail" - -# Check container logs -docker logs prometheus --tail 50 -docker logs grafana --tail 50 -docker logs loki --tail 50 -docker logs promtail --tail 50 -``` - -### 2. Verify Network Connectivity -```bash -# Check fieldtrack_network exists -docker network ls | grep fieldtrack - -# Verify containers on network -docker network inspect fieldtrack_network | grep Name -``` - -### 3. Test Individual Components -```bash -# Test Prometheus -curl http://localhost:9090/-/healthy - -# Test Grafana -curl http://localhost:3333/api/health - -# Test Loki -curl http://localhost:3100/ready - -# Test backend metrics endpoint -curl -H "Authorization: Bearer $METRICS_SCRAPE_TOKEN" http://localhost:3001/metrics -``` - -## Recovery Procedures - -### Restart Monitoring Stack -```bash -cd /home/ashish/FieldTrack-2.0/infra - -# Stop all monitoring containers -docker compose --env-file .env.monitoring -f docker-compose.monitoring.yml down - -# Start fresh -docker compose --env-file .env.monitoring -f docker-compose.monitoring.yml up -d - -# Verify startup -docker compose --env-file .env.monitoring -f docker-compose.monitoring.yml ps -``` - -### Restart Individual Service - -#### Prometheus -```bash -docker restart prometheus - -# Verify scrape targets -curl http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job, health}' -``` - -#### Grafana -```bash -docker restart grafana - -# Check datasources -curl -u admin:$GRAFANA_PASSWORD http://localhost:3333/api/datasources -``` - -#### Loki -```bash -docker restart loki - -# Test log ingestion -curl http://localhost:3100/loki/api/v1/labels -``` - -#### Promtail -```bash -docker restart promtail - -# Check targets -curl http://localhost:9080/targets -``` - -## Root Cause Analysis - -### Common Issues - -1. **Prometheus Not Scraping** - ```bash - # Check scrape config - docker exec prometheus cat /etc/prometheus/prometheus.yml - - # Verify METRICS_SCRAPE_TOKEN matches - grep METRICS_SCRAPE_TOKEN infra/.env.monitoring - - # Check backend metrics endpoint - curl -H "Authorization: Bearer $METRICS_SCRAPE_TOKEN" http://backend-blue:3000/metrics - ``` - -2. **Grafana Datasource Failure** - ```bash - # Check Grafana logs for datasource errors - docker logs grafana | grep -i "datasource" - - # Verify Prometheus URL in Grafana - # Should be: http://prometheus:9090 - - # Verify Loki URL in Grafana - # Should be: http://loki:3100 - ``` - -3. **Loki Not Receiving Logs** - ```bash - # Check Promtail is running - docker ps | grep promtail - - # Verify Promtail config - docker exec promtail cat /etc/promtail/promtail.yml - - # Check Promtail can reach Loki - docker exec promtail wget -O- http://loki:3100/ready - ``` - -4. **Disk Space Full** - ```bash - # Check disk usage - df -h - - # Check Prometheus data size - du -sh infra/prometheus/data/ - - # Check Loki data size - du -sh infra/loki/ - - # Clean old data if needed (Loki has 30d retention) - ``` - -5. **Configuration Drift** - ```bash - # Verify config hash - cat ~/.fieldtrack-monitoring-hash - - # Force config reload - rm ~/.fieldtrack-monitoring-hash - cd /home/ashish/FieldTrack-2.0 - export DEPLOY_ROOT=/home/ashish/FieldTrack-2.0 - ./apps/api/scripts/deploy-bluegreen.sh $(head -1 apps/api/.deploy_history) - ``` - -## Configuration Validation - -### Prometheus -```bash -# Validate prometheus.yml -docker run --rm -v $(pwd)/infra/prometheus:/prometheus prom/prometheus:latest \ - promtool check config /prometheus/prometheus.yml - -# Validate alerts.yml -docker run --rm -v $(pwd)/infra/prometheus:/prometheus prom/prometheus:latest \ - promtool check rules /prometheus/alerts.yml -``` - -### Loki -```bash -# Validate loki-config.yaml -docker run --rm -v $(pwd)/infra/loki:/loki grafana/loki:2.9.6 \ - -config.file=/loki/loki-config.yaml -verify-config -``` - -### Promtail -```bash -# Validate promtail.yml -docker run --rm -v $(pwd)/infra/promtail:/promtail grafana/promtail:2.9.6 \ - -config.file=/promtail/promtail.yml -dry-run -``` - -## Data Recovery - -### Prometheus Data Loss -```bash -# Prometheus stores 15 days by default -# Data is in infra/prometheus/data/ -# If corrupted, stop Prometheus and delete data dir -docker stop prometheus -rm -rf infra/prometheus/data/* -docker start prometheus -# Metrics will rebuild from current scrapes -``` - -### Loki Data Loss -```bash -# Loki stores 30 days (configured in loki-config.yaml) -# Data is in infra/loki/chunks/ -# If corrupted, stop Loki and delete chunks -docker stop loki -rm -rf infra/loki/chunks/* -docker start loki -# Logs will rebuild from Promtail -``` - -### Grafana Dashboard Loss -```bash -# Dashboards are in infra/grafana/dashboards/ -# If lost, restore from git -cd /home/ashish/FieldTrack-2.0 -git checkout infra/grafana/dashboards/ -docker restart grafana -``` - -## Prevention - -### Regular Maintenance -```bash -# Weekly: Check disk space -df -h - -# Weekly: Verify all targets healthy -curl http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | select(.health != "up")' - -# Monthly: Review retention policies -# Prometheus: 15d default -# Loki: 30d configured -``` - -### Monitoring the Monitors -- Set up external uptime monitoring for Grafana -- Configure Prometheus to alert on its own health -- Document baseline resource usage - -### Backup Strategy -```bash -# Backup Grafana dashboards -cp -r infra/grafana/dashboards/ ~/backups/grafana-$(date +%Y%m%d)/ - -# Backup Prometheus config -cp infra/prometheus/*.yml ~/backups/prometheus-$(date +%Y%m%d)/ - -# Backup Loki config -cp infra/loki/*.yaml ~/backups/loki-$(date +%Y%m%d)/ -``` - -## Escalation - -If monitoring cannot be restored: -1. Application continues running (monitoring is non-critical) -2. Check GitHub Actions logs for deployment issues -3. Review recent infrastructure changes -4. Consider fresh monitoring stack deployment -5. Engage DevOps team if data corruption suspected diff --git a/apps/api/errors.txt b/apps/api/errors.txt deleted file mode 100644 index bd53885..0000000 --- a/apps/api/errors.txt +++ /dev/null @@ -1,52 +0,0 @@ -node_modules/zod/v4/locales/index.d.cts(1,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ar"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(2,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/az"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(3,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/be"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(4,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/bg"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(5,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ca"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(6,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/cs"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(7,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/da"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(8,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/de"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(9,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/en"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(10,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/eo"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(11,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/es"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(12,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/fa"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(13,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/fi"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(14,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/fr"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(15,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/fr-CA"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(16,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/he"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(17,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/hu"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(18,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/hy"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(19,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/id"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(20,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/is"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(21,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/it"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(22,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ja"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(23,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ka"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(24,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/kh"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(25,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/km"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(26,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ko"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(27,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/lt"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(28,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/mk"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(29,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ms"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(30,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/nl"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(31,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/no"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(32,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ota"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(33,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ps"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(34,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/pl"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(35,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/pt"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(36,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ru"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(37,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/sl"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(38,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/sv"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(39,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ta"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(40,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/th"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(41,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/tr"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(42,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ua"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(43,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/uk"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(44,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/ur"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(45,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/uz"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(46,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/vi"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(47,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/zh-CN"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(48,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/zh-TW"' can only be default-imported using the 'esModuleInterop' flag -node_modules/zod/v4/locales/index.d.cts(49,21): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/zod/v4/locales/yo"' can only be default-imported using the 'esModuleInterop' flag -src/auth/jwtVerifier.ts(1,8): error TS1259: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/jwks-rsa/index"' can only be default-imported using the 'esModuleInterop' flag -src/auth/jwtVerifier.ts(2,8): error TS1192: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/@types/jsonwebtoken/index"' has no default export. -src/config/env.ts(28,8): error TS1192: Module '"D:/Codebase/FieldTrack-2.0/apps/api/node_modules/dotenv/lib/main"' has no default export. diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index 4cc1cd5..0000000 --- a/apps/api/package.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "name": "fieldtrack-backend", - "version": "1.0.0", - "description": "FieldTrack 2.0 Backend API", - "main": "dist/server.js", - "engines": { - "node": ">=24.0.0" - }, - "scripts": { - "dev": "tsx watch src/server.ts", - "build": "tsc", - "typecheck": "tsc --noEmit", - "lint": "eslint src/modules --ext .ts", - "start": "node dist/server.js", - "test": "vitest run", - "test:watch": "vitest", - "analytics:backfill": "tsx scripts/analytics-backfill.ts" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "module", - "dependencies": { - "@fastify/compress": "^8.3.1", - "@fastify/cors": "^11.2.0", - "@fastify/helmet": "^13.0.2", - "@fastify/jwt": "^10.0.0", - "@fastify/rate-limit": "^10.3.0", - "@fastify/swagger": "^9.7.0", - "@fastify/swagger-ui": "^5.2.5", - "@fieldtrack/types": "*", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/auto-instrumentations-node": "^0.71.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", - "@opentelemetry/resources": "^2.0.0", - "@opentelemetry/sdk-node": "^0.213.0", - "@opentelemetry/sdk-trace-base": "^2.0.0", - "@scalar/fastify-api-reference": "^1.48.2", - "@supabase/supabase-js": "^2.99.0", - "@types/jsonwebtoken": "^9.0.10", - "bullmq": "^5.70.4", - "dotenv": "^17.3.1", - "fastify": "^5.8.3", - "fastify-plugin": "^5.1.0", - "fastify-type-provider-zod": "^6.1.0", - "ioredis": "^5.10.0", - "jsonwebtoken": "^9.0.3", - "jwks-rsa": "^4.0.1", - "prom-client": "^15.1.3", - "uuid": "^13.0.0", - "zod": "^4.3.6" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/node": "^25.4.0", - "@vitest/coverage-v8": "^4.0.18", - "eslint": "^10.0.3", - "pino-pretty": "^13.1.3", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "typescript-eslint": "^8.57.0", - "vitest": "^4.0.18" - }, - "optionalDependencies": { - "@rollup/rollup-darwin-x64": "^4.0.0", - "@rollup/rollup-linux-x64-gnu": "^4.0.0" - } -} diff --git a/apps/api/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts deleted file mode 100644 index e6ce1d5..0000000 --- a/apps/api/src/modules/profile/profile.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { FastifyRequest } from "fastify"; -import { profileRepository, computeActivityStatusFromTimestamp } from "./profile.repository.js"; -import { NotFoundError, ForbiddenError } from "../../utils/errors.js"; -import type { EmployeeProfileData } from "@fieldtrack/types"; - -export const profileService = { - /** - * Get the requesting employee's own profile. - * Requires an employee context (request.employeeId). - */ - async getMyProfile(request: FastifyRequest): Promise { - const employeeId = request.employeeId; - if (!employeeId) { - throw new ForbiddenError("No employee profile linked to this account"); - } - - return this.getEmployeeProfile(request, employeeId); - }, - - /** - * Get any employee profile (admin access). - * The employee must belong to the admin's organization. - */ - async getEmployeeProfile( - request: FastifyRequest, - employeeId: string, - ): Promise { - const employee = await profileRepository.getEmployeeById(request, employeeId); - if (!employee) { - throw new NotFoundError("Employee not found"); - } - - const [stats, expenseStats] = await Promise.all([ - profileRepository.getEmployeeStats(request, employeeId), - profileRepository.getEmployeeExpenseStats(request, employeeId), - ]); - - return { - id: employee.id, - name: employee.name, - employee_code: employee.employee_code, - phone: employee.phone, - is_active: employee.is_active, - activityStatus: computeActivityStatusFromTimestamp(employee.last_activity_at), - last_activity_at: employee.last_activity_at, - created_at: employee.created_at, - stats: { - totalSessions: stats.totalSessions, - totalDistanceKm: stats.totalDistanceKm, - totalDurationSeconds: stats.totalDurationSeconds, - expensesSubmitted: expenseStats.expensesSubmitted, - expensesApproved: expenseStats.expensesApproved, - }, - }; - }, -}; diff --git a/apps/api/src/utils/dedup.ts b/apps/api/src/utils/dedup.ts deleted file mode 100644 index 577a5e7..0000000 --- a/apps/api/src/utils/dedup.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Phase 22: In-flight request deduplication. - * - * Prevents cache-miss stampedes on expensive endpoints (e.g. /admin/dashboard) - * when multiple identical requests arrive concurrently before the first one - * has written its result to Redis. - * - * Usage: - * const data = await deduped(`dashboard:${orgId}`, () => expensiveQuery()); - * - * Guarantees: - * - Only ONE underlying call executes for a given key at any instant. - * - All concurrent callers resolve to the SAME promise (same data, same timing). - * - The key is removed from the map as soon as the promise settles (resolve OR reject), - * so the next caller after the first finishes starts a fresh execution. - * - Errors propagate to all waiters; they are not swallowed. - * - * Thread-safety note: Node.js is single-threaded, so the map read-check-set - * sequence is atomic and race-free within a single process. - */ - -// Module-level singleton — shared across all callers in this process. -const inflight = new Map>(); - -/** - * Return the in-flight promise for `key` if one exists; otherwise call `fn`, - * store the resulting promise under `key`, and return it. - */ -export function deduped(key: string, fn: () => Promise): Promise { - const existing = inflight.get(key); - if (existing !== undefined) return existing as Promise; - - const promise = fn().finally(() => { - inflight.delete(key); - }); - - inflight.set(key, promise); - return promise; -} diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index fe47189..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,47 +0,0 @@ -# ============================================ -# Frontend Environment Variables -# ============================================ -# Copy this file to .env.local and fill in the values -# .env.local is gitignored and will not be committed - -# ---------------- -# Backend API -# ---------------- -# Controls how the browser reaches the backend API. -# -# Mode A — Direct (recommended for Vercel / production): -# Set to the full API URL so the browser calls it directly. -NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 -# -# Mode B — Server-side proxy (avoids CORS, hides API origin from browser): -# Set to /api/proxy so all browser traffic routes through Next.js. -# You MUST also set API_DESTINATION_URL (below) — Next.js will forward -# /api/proxy/:path* → /:path* on the server. -# NEXT_PUBLIC_API_BASE_URL=/api/proxy -# -# IMPORTANT: Variable was renamed from NEXT_PUBLIC_API_URL to -# NEXT_PUBLIC_API_BASE_URL. If your Vercel project still uses the old name, -# update it in Vercel project settings or it will be picked up automatically -# as a fallback for one release cycle. - -# Server-side proxy destination (only needed when Mode B is active above). -# This is NOT baked into the client JS — it is read only by the Next.js server. -# API_DESTINATION_URL=https://api.getfieldtrack.app - -# ---------------- -# Supabase -# ---------------- -# Supabase project URL -# Find this in your Supabase project settings -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co - -# Supabase anonymous/public key -# Find this in your Supabase project settings under API -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key - -# ---------------- -# Mapbox -# ---------------- -# Mapbox access token for map rendering -# Get this from https://account.mapbox.com/access-tokens/ -NEXT_PUBLIC_MAPBOX_TOKEN=your-mapbox-token diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json deleted file mode 100644 index b896beb..0000000 --- a/apps/web/.eslintrc.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "rules": { - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "axios", - "message": "Do not use axios directly. Use the API client (apiGet/apiPost/apiPatch) from @/lib/api/client instead." - }, - { - "name": "node-fetch", - "message": "Do not use node-fetch directly. Use the API client from @/lib/api/client instead." - }, - { - "name": "ky", - "message": "Do not use ky directly. Use the API client from @/lib/api/client instead." - } - ] - } - ], - "no-restricted-syntax": [ - "error", - { - "selector": "CallExpression[callee.name='fetch'] > Literal[value=/^\\/(?!api\\/proxy)/]", - "message": "Do not call fetch() with a relative path. Use the API client (apiGet/apiPost/apiPatch) from @/lib/api/client instead." - }, - { - "selector": "CallExpression[callee.name='fetch'] > TemplateLiteral > TemplateElement[value.cooked=/^\\/(?!api\\/proxy)/]", - "message": "Do not call fetch() with a relative path. Use the API client (apiGet/apiPost/apiPatch) from @/lib/api/client instead." - } - ] - } -} diff --git a/apps/web/README.md b/apps/web/README.md deleted file mode 100644 index 7b4adcc..0000000 --- a/apps/web/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# FieldTrack 2.0 — Frontend - -Modern Next.js 15 frontend for the FieldTrack 2.0 workforce management platform. - -## Tech Stack - -- **Framework**: Next.js 15 (App Router) -- **Language**: TypeScript 5.9 -- **Styling**: TailwindCSS 4 -- **UI Components**: shadcn/ui -- **Maps**: Mapbox GL JS -- **Auth**: Supabase Auth -- **State Management**: React Query (TanStack Query) -- **Forms**: React Hook Form + Zod validation - -## Quick Start - -```bash -# Install dependencies -npm install - -# Copy environment template and fill in values -cp .env.example .env.local - -# Start development server -npm run dev -``` - -The app will be available at `http://localhost:3000`. - -## Environment Variables - -Required environment variables (see `.env.example`): - -- `NEXT_PUBLIC_API_BASE_URL`: Backend API URL - - Development: `http://localhost:3001` - - Production: `https://api.fieldtrack.meowsician.tech` -- `NEXT_PUBLIC_SUPABASE_URL`: Supabase project URL -- `NEXT_PUBLIC_SUPABASE_ANON_KEY`: Supabase anonymous key -- `NEXT_PUBLIC_MAPBOX_TOKEN`: Mapbox access token - -## Scripts - -| Command | Description | -|---------|-------------| -| `npm run dev` | Start development server with hot reload | -| `npm run build` | Build production bundle | -| `npm start` | Run production server | -| `npm run lint` | Run ESLint | -| `npm run type-check` | Run TypeScript compiler check | - -## Project Structure - -``` -src/ -├── app/ # Next.js App Router pages -│ ├── (auth)/ # Authentication pages (login, signup) -│ ├── (dashboard)/ # Protected dashboard pages -│ └── layout.tsx # Root layout -├── components/ # React components -│ ├── ui/ # shadcn/ui components -│ └── ... # Feature components -├── lib/ # Utilities and configurations -│ ├── api/ # API client and hooks -│ ├── env.ts # Environment variable validation -│ └── utils.ts # Shared utilities -└── types/ # TypeScript type definitions -``` - -## Architecture - -### Domain Architecture - -- **Frontend**: `https://fieldtrack.meowsician.tech` -- **API**: `https://api.fieldtrack.meowsician.tech` - -### API Communication - -The frontend communicates with the backend API using environment-based configuration: - -- **Local Development**: Direct connection to `http://localhost:3001` -- **Production**: Uses Next.js API proxy (`/api/proxy/*`) to avoid CORS issues - -The API client (`src/lib/api/client.ts`) automatically uses the `NEXT_PUBLIC_API_BASE_URL` environment variable. - -## Deployment - -The frontend is deployed to Vercel with automatic deployments on push to `master`. - -### Environment Variables (Vercel) - -Set these in your Vercel project settings: - -```bash -NEXT_PUBLIC_API_BASE_URL=/api/proxy -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key -NEXT_PUBLIC_MAPBOX_TOKEN=your-mapbox-token -``` - -Note: In production, `NEXT_PUBLIC_API_BASE_URL` should be set to `/api/proxy` to use the Next.js server-side proxy, which avoids CORS preflight requests. - -## Features - -- **Authentication**: Secure login/signup with Supabase Auth -- **Dashboard**: Real-time workforce overview and analytics -- **Attendance Tracking**: Check-in/check-out with GPS location -- **Expense Management**: Submit and track expense claims -- **Location Tracking**: Real-time GPS tracking on interactive maps -- **Admin Analytics**: Organization-wide insights and reports -- **Responsive Design**: Mobile-first, works on all devices - -## Documentation - -For more information, see: - -- [Project Structure](STRUCTURE.md) - Detailed frontend architecture -- [API Reference](../docs/API_REFERENCE.md) - Backend API documentation -- [Architecture](../docs/ARCHITECTURE.md) - System design and data flows - -## License - -[MIT](../../LICENSE) © 2026 Ashish Raj diff --git a/apps/web/STRUCTURE.md b/apps/web/STRUCTURE.md deleted file mode 100644 index e95ca49..0000000 --- a/apps/web/STRUCTURE.md +++ /dev/null @@ -1,205 +0,0 @@ -# Frontend Structure Chart - -## Overview -Next.js 14+ application with App Router, TypeScript, TailwindCSS, and React Query for state management. - -## Directory Structure - -``` -frontend/ -├── .next/ # Next.js build output (auto-generated) -├── node_modules/ # Dependencies (auto-generated) -├── public/ # Static assets -├── src/ # Source code -│ ├── app/ # Next.js App Router pages -│ │ ├── (protected)/ # Protected routes (requires auth) -│ │ │ ├── analytics/ # Analytics dashboard -│ │ │ ├── attendance/ # Attendance management -│ │ │ ├── expenses/ # Expense tracking -│ │ │ ├── locations/ # Location management -│ │ │ └── layout.tsx # Protected layout wrapper -│ │ ├── login/ # Login page -│ │ │ └── page.tsx -│ │ ├── globals.css # Global styles -│ │ ├── layout.tsx # Root layout -│ │ ├── page.tsx # Home/landing page -│ │ └── providers.tsx # App-level providers -│ │ -│ ├── components/ # Reusable components -│ │ ├── charts/ # Chart components (Recharts) -│ │ │ ├── AttendanceChart.tsx -│ │ │ ├── ExpenseChart.tsx -│ │ │ └── ... -│ │ ├── layout/ # Layout components -│ │ │ ├── Header.tsx -│ │ │ ├── Sidebar.tsx -│ │ │ └── ... -│ │ ├── maps/ # Map components (Mapbox) -│ │ │ ├── AttendanceMap.tsx -│ │ │ ├── LocationMap.tsx -│ │ │ └── ... -│ │ ├── tables/ # Table components -│ │ │ ├── AttendanceTable.tsx -│ │ │ ├── ExpenseTable.tsx -│ │ │ └── ... -│ │ ├── ui/ # shadcn/ui components -│ │ │ ├── button.tsx -│ │ │ ├── card.tsx -│ │ │ ├── dialog.tsx -│ │ │ ├── dropdown-menu.tsx -│ │ │ ├── input.tsx -│ │ │ ├── label.tsx -│ │ │ ├── select.tsx -│ │ │ ├── separator.tsx -│ │ │ ├── tabs.tsx -│ │ │ ├── toast.tsx -│ │ │ ├── toaster.tsx -│ │ │ └── use-toast.ts -│ │ ├── EmptyState.tsx # Empty state component -│ │ ├── ErrorBanner.tsx # Error display component -│ │ └── LoadingSkeleton.tsx # Loading state component -│ │ -│ ├── contexts/ # React contexts -│ │ └── AuthContext.tsx # Authentication context -│ │ -│ ├── hooks/ # Custom React hooks -│ │ ├── queries/ # React Query hooks -│ │ │ ├── useAnalytics.ts -│ │ │ ├── useAttendance.ts -│ │ │ ├── useExpenses.ts -│ │ │ └── useLocations.ts -│ │ └── useAuth.ts # Authentication hook -│ │ -│ ├── lib/ # Utility libraries -│ │ ├── api.ts # API client configuration -│ │ ├── env.ts # Environment variables -│ │ ├── permissions.ts # Permission utilities -│ │ ├── query-client.ts # React Query client -│ │ ├── supabase.ts # Supabase client -│ │ └── utils.ts # General utilities -│ │ -│ └── types/ # TypeScript type definitions -│ └── index.ts # Shared types -│ -├── .eslintrc.json # ESLint configuration -├── .gitignore # Git ignore rules -├── components.json # shadcn/ui configuration -├── next.config.mjs # Next.js configuration -├── next-env.d.ts # Next.js TypeScript declarations -├── package.json # Dependencies and scripts -├── package-lock.json # Dependency lock file -├── postcss.config.mjs # PostCSS configuration -├── README.md # Frontend documentation -├── STRUCTURE.md # This file -├── tailwind.config.ts # TailwindCSS configuration -└── tsconfig.json # TypeScript configuration - -``` - -## Key Technologies - -### Core Framework -- **Next.js 14+**: React framework with App Router -- **React 18**: UI library -- **TypeScript**: Type safety - -### Styling -- **TailwindCSS**: Utility-first CSS framework -- **shadcn/ui**: Reusable component library -- **Radix UI**: Headless UI primitives - -### State Management -- **React Query (@tanstack/react-query)**: Server state management -- **React Context**: Client state management - -### Data Visualization -- **Recharts**: Chart library -- **Mapbox GL**: Interactive maps - -### Authentication -- **Supabase**: Authentication and database client - -### Development Tools -- **ESLint**: Code linting -- **PostCSS**: CSS processing -- **Autoprefixer**: CSS vendor prefixing - -## Routing Structure - -``` -/ # Landing page -/login # Login page -/(protected)/ # Protected routes (requires authentication) - ├── /analytics # Analytics dashboard - ├── /attendance # Attendance management - ├── /expenses # Expense tracking - └── /locations # Location management -``` - -## Component Architecture - -### UI Components (shadcn/ui) -Reusable, accessible components built on Radix UI primitives: -- Buttons, Inputs, Labels -- Cards, Dialogs, Dropdowns -- Tabs, Toasts, Separators - -### Feature Components -Domain-specific components organized by feature: -- **Charts**: Data visualization components -- **Maps**: Geographic visualization components -- **Tables**: Data table components -- **Layout**: Navigation and structure components - -### State Components -- **EmptyState**: Display when no data available -- **ErrorBanner**: Error message display -- **LoadingSkeleton**: Loading state placeholders - -## Data Flow - -1. **Authentication**: Supabase Auth → AuthContext → Protected Routes -2. **API Calls**: React Query hooks → API client → Backend -3. **State Management**: React Query cache + React Context -4. **UI Updates**: Query invalidation → Automatic refetch → UI update - -## Environment Variables - -Required environment variables (see `.env.example`): -- `NEXT_PUBLIC_API_BASE_URL`: Backend API URL -- `NEXT_PUBLIC_SUPABASE_URL`: Supabase project URL -- `NEXT_PUBLIC_SUPABASE_ANON_KEY`: Supabase anonymous key - -## Build & Development - -### Development -```bash -npm run dev # Start development server (port 3000) -npm run lint # Run ESLint -``` - -### Production -```bash -npm run build # Build for production -npm start # Start production server -``` - -## Code Organization Principles - -1. **Feature-based organization**: Components grouped by feature/domain -2. **Separation of concerns**: UI, logic, and data layers separated -3. **Reusability**: Shared components in `components/ui/` -4. **Type safety**: TypeScript throughout -5. **Server state**: Managed by React Query -6. **Client state**: Managed by React Context when needed - -## Best Practices - -- Use React Query for all server state -- Keep components small and focused -- Use TypeScript for type safety -- Follow Next.js App Router conventions -- Use shadcn/ui components for consistency -- Implement proper error boundaries -- Use loading states for better UX -- Follow accessibility guidelines (WCAG) diff --git a/apps/web/components.json b/apps/web/components.json deleted file mode 100644 index dba50c6..0000000 --- a/apps/web/components.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "src/app/globals.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } -} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs deleted file mode 100644 index 6173a49..0000000 --- a/apps/web/next.config.mjs +++ /dev/null @@ -1,115 +0,0 @@ -/** @type {import('next').NextConfig} */ - -const isDev = process.env.NODE_ENV === 'development'; - -// NEXT_PUBLIC_API_BASE_URL controls how the browser reaches the backend. -// -// Mode A — Direct (recommended for Vercel): -// NEXT_PUBLIC_API_BASE_URL=https://api.getfieldtrack.app -// Browser calls the API directly; no server-side proxy is involved. -// -// Mode B — Server-side proxy (avoids CORS, hides API origin from browser): -// NEXT_PUBLIC_API_BASE_URL=/api/proxy -// API_DESTINATION_URL=https://api.getfieldtrack.app ← server-only, never baked into JS -// Browser calls /api/proxy/:path*, Next.js rewrites to API_DESTINATION_URL/:path*. -// API_DESTINATION_URL MUST be set when using proxy mode or the rewrite has no destination. -// -// CI placeholder builds may use NEXT_PUBLIC_API_BASE_URL=/api/proxy without -// API_DESTINATION_URL — this is fine because no real requests are made during `next build`. - -const NEXT_PUBLIC_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; -const API_DESTINATION_URL = process.env.API_DESTINATION_URL ?? ""; - -const apiIsFullUrl = /^https?:\/\//.test(NEXT_PUBLIC_API_BASE_URL); -const destinationIsFullUrl = /^https?:\/\//.test(API_DESTINATION_URL); - -// In direct mode, extract the origin from NEXT_PUBLIC_API_BASE_URL. -// In proxy mode, extract it from API_DESTINATION_URL (server-only var). -// Used for CSP and as the rewrite destination. -const apiOrigin = apiIsFullUrl - ? new URL(NEXT_PUBLIC_API_BASE_URL).origin - : destinationIsFullUrl - ? new URL(API_DESTINATION_URL).origin - : ""; - -const nextConfig = { - transpilePackages: ["mapbox-gl", "@fieldtrack/types"], - images: { - domains: [], - // Mitigate GHSA-3x4c-7xq6-9pq8 (unbounded Next.js image disk cache growth). - // Limit format variants and enforce TTL so stale image cache entries expire. - // Full fix: upgrade to next@>=16.1.7 when breaking changes are reviewed. - formats: ["image/webp"], - minimumCacheTTL: 3600, - }, - async headers() { - const connectSources = [ - "'self'", - "https://*.supabase.co", // Supabase auth, realtime, storage - "https://*.tiles.mapbox.com", // Mapbox raster / vector tiles - "https://api.mapbox.com", // Mapbox geocoding, directions, styles - "https://events.mapbox.com", // Mapbox telemetry - "https://*.tile.openstreetmap.org", // Leaflet / OpenStreetMap tiles - ]; - // Only add the API origin when it is a full URL — same-origin requests - // (/api/proxy path) are already covered by 'self' above. - // In proxy mode, apiOrigin is derived from API_DESTINATION_URL (server-only) so - // it is NOT embedded in the client bundle, but it IS the rewrite destination. - if (apiOrigin) { - connectSources.push(apiOrigin); - } - - return [ - { - source: "/:path*", - headers: [ - { key: "X-Frame-Options", value: "SAMEORIGIN" }, - { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - // In development, Next.js Fast Refresh (HMR) requires 'unsafe-eval'. - // Without it the React event system breaks and forms submit natively. - isDev ? "script-src 'self' 'unsafe-inline' 'unsafe-eval'" : "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - // blob: required for Mapbox GL sprite / image atlas - "img-src 'self' data: blob: https:", - "font-src 'self' data:", - // Mapbox GL v3 spawns blob: Web Workers for tile decoding - "worker-src blob:", - "child-src blob:", - `connect-src ${connectSources.join(" ")}`, - "frame-ancestors 'self'", - ].join("; "), - }, - ], - }, - ]; - }, - async rewrites() { - // The /api/proxy rewrite forwards browser requests to the real backend. - // - // In direct mode (NEXT_PUBLIC_API_BASE_URL = full URL): - // apiOrigin is derived from that URL — rewrite is available as a convenience - // but the client calls the API directly and never hits /api/proxy. - // - // In proxy mode (NEXT_PUBLIC_API_BASE_URL = /api/proxy): - // apiOrigin is derived from API_DESTINATION_URL — the rewrite is REQUIRED - // because the browser sends every request to /api/proxy/:path*. - // Without it, Next.js returns a 404 HTML page and all JSON parsing fails. - // - // When no destination is resolvable (e.g. CI placeholder builds) the rewrite - // is skipped — this is safe because no real requests are made during `next build`. - if (!apiOrigin) return []; - return [ - { - source: "/api/proxy/:path*", - destination: `${apiOrigin}/:path*`, - }, - ]; - }, -}; - -export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index 19d2cce..0000000 --- a/apps/web/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "fieldtrack-frontend", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "type-check": "tsc --noEmit", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage" - }, - "dependencies": { - "@fieldtrack/types": "*", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-dropdown-menu": "^2.1.4", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", - "@radix-ui/react-toast": "^1.2.4", - "@supabase/ssr": "^0.5.2", - "@supabase/supabase-js": "^2.46.2", - "@tanstack/react-query": "^5.62.7", - "@types/leaflet": "^1.9.21", - "@types/leaflet.markercluster": "^1.5.6", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "framer-motion": "^12.36.0", - "leaflet": "^1.9.4", - "leaflet.markercluster": "^1.5.3", - "lucide-react": "^0.468.0", - "mapbox-gl": "^3.8.0", - "next": "^15.1.3", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-leaflet": "^5.0.0", - "recharts": "^2.13.3", - "tailwind-merge": "^2.5.5", - "zod": "^4.3.6" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.1.0", - "@testing-library/user-event": "^14.5.2", - "@types/mapbox-gl": "^3.4.1", - "@types/node": "^22.10.2", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^2.1.8", - "autoprefixer": "^10.4.20", - "eslint": "^8.57.1", - "eslint-config-next": "^15.1.3", - "jsdom": "^25.0.1", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.17", - "typescript": "^5.7.2", - "vitest": "^2.1.8" - } -} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs deleted file mode 100644 index 73a0f54..0000000 --- a/apps/web/postcss.config.mjs +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('postcss').Config} */ -const config = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; - -export default config; diff --git a/apps/web/public/logo/logo.png b/apps/web/public/logo/logo.png deleted file mode 100644 index 4156210..0000000 Binary files a/apps/web/public/logo/logo.png and /dev/null differ diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx deleted file mode 100644 index f7b1db8..0000000 --- a/apps/web/src/app/(auth)/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Auth layout — intentionally bare. - * Pages inside (auth) do not use AppLayout so the login screen is standalone. - */ -export default function AuthLayout({ children }: { children: React.ReactNode }) { - return <>{children}; -} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx deleted file mode 100644 index c0f7416..0000000 --- a/apps/web/src/app/(auth)/login/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import Image from "next/image"; -import { useAuth } from "@/hooks/useAuth"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { cn } from "@/lib/utils"; - -export default function LoginPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { login } = useAuth(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setIsLoading(true); - try { - const userRole = await login(email, password); - const next = searchParams.get("next"); - if (next && next.startsWith("/") && !next.startsWith("//")) { - router.push(next); - } else if (userRole === "ADMIN") { - router.push("/dashboard"); - } else { - router.push("/sessions"); - } - } catch (err) { - setError(err instanceof Error ? err : new Error("Login failed")); - } finally { - setIsLoading(false); - } - } - - return ( -
- {/* Ambient background glow */} -
-
-
- -
- {/* Logo + wordmark */} -
-
- FieldTrack -
-
-

FieldTrack

-

Field workforce tracking & management

-
-
- - {/* Auth card */} -
-
-

Sign in to your account

-

Enter your credentials to continue

-
- -
void handleSubmit(e)} className="space-y-4"> - {error && } - -
- - setEmail(e.target.value)} - required - autoComplete="email" - className="h-10" - /> -
- -
- - setPassword(e.target.value)} - required - autoComplete="current-password" - className="h-10" - /> -
- - - -
- -

- FieldTrack 2.0 — Secure field workforce management -

-
-
- ); -} diff --git a/apps/web/src/app/(protected)/admin/analytics/page.tsx b/apps/web/src/app/(protected)/admin/analytics/page.tsx deleted file mode 100644 index 37f628b..0000000 --- a/apps/web/src/app/(protected)/admin/analytics/page.tsx +++ /dev/null @@ -1,425 +0,0 @@ -"use client"; - -import { useState, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { motion, AnimatePresence } from "framer-motion"; -import { useAuth } from "@/hooks/useAuth"; -import { - useOrgSummary, - useTopPerformers, - useSessionTrend, - useLeaderboard, -} from "@/hooks/queries/useAnalytics"; -import { TopPerformersChart } from "@/components/charts/TopPerformersChart"; -import { SessionTrendChart } from "@/components/charts/SessionTrendChart"; -import { LeaderboardTable } from "@/components/charts/LeaderboardTable"; -import { MetricCard } from "@/components/MetricCard"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { StaggerList, StaggerItem, FadeUp } from "@/components/motion"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Input } from "@/components/ui/input"; -import { formatDistance, formatDuration, formatCurrency } from "@/lib/utils"; -import { Activity, MapPin, Clock, Receipt, Users, TrendingUp, Trophy, Calendar } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - type DateRange, - type PresetKey, - PRESET_LABELS, - rangeForPreset, - loadPersistedPreset, - loadPersistedCustomRange, - persistPreset, - persistCustomRange, - toInputDate, - formatRangeLabel, -} from "@/lib/dateRange"; -import type { OrgSummaryData } from "@/types"; - -// --- Date Range Filter Bar --------------------------------------------------- - -const PRESETS: PresetKey[] = [ - "today", - "yesterday", - "7d", - "30d", - "thisMonth", - "lastMonth", - "custom", -]; - -interface DateRangeFilterProps { - preset: PresetKey; - customRange: DateRange | null; - activeRange: DateRange; - onChange: (preset: PresetKey, customRange?: DateRange) => void; -} - -function DateRangeFilter({ - preset, - customRange, - activeRange, - onChange, -}: DateRangeFilterProps) { - const [showCustom, setShowCustom] = useState(preset === "custom"); - const [localFrom, setLocalFrom] = useState( - customRange ? customRange.from.slice(0, 10) : toInputDate(new Date()) - ); - const [localTo, setLocalTo] = useState( - customRange ? customRange.to.slice(0, 10) : toInputDate(new Date()) - ); - - function handlePreset(p: PresetKey) { - if (p === "custom") { - setShowCustom(true); - onChange("custom", customRange ?? undefined); - } else { - setShowCustom(false); - onChange(p); - } - } - - function handleApply() { - if (!localFrom || !localTo) return; - const from = new Date(localFrom); - from.setHours(0, 0, 0, 0); - const to = new Date(localTo); - to.setHours(23, 59, 59, 999); - if (from > to) return; - const range: DateRange = { from: from.toISOString(), to: to.toISOString() }; - onChange("custom", range); - } - - return ( -
-
- {PRESETS.map((p) => ( - - ))} -
- - - {showCustom && ( - -
-
- Start Date - setLocalFrom(e.target.value)} - className="h-8 text-sm" - /> -
-
- End Date - setLocalTo(e.target.value)} - className="h-8 text-sm" - /> -
- -
-
- )} -
- -

- - {formatRangeLabel(activeRange)} -

-
- ); -} - -// --- Analytics Metric Cards -------------------------------------------------- - -function AnalyticsMetrics({ - summary, - isLoading, -}: { - summary?: OrgSummaryData; - isLoading: boolean; -}) { - const cards = [ - { - title: "Sessions", - value: summary?.totalSessions.toLocaleString() ?? "—", - numericValue: summary?.totalSessions, - icon: , - }, - { - title: "Distance", - value: summary ? formatDistance(summary.totalDistanceKm) : "—", - icon: , - }, - { - title: "Duration", - value: summary ? formatDuration(summary.totalDurationSeconds) : "—", - icon: , - }, - { - title: "Active Employees", - value: summary?.activeEmployeesCount.toLocaleString() ?? "—", - numericValue: summary?.activeEmployeesCount, - icon: , - highlighted: true, - }, - { - title: "Approved Expenses", - value: summary ? formatCurrency(summary.approvedExpenseAmount) : "—", - icon: , - }, - { - title: "Expense Requests", - value: summary?.totalExpenses.toLocaleString() ?? "—", - numericValue: summary?.totalExpenses, - icon: , - }, - ]; - - return ( - - {cards.map((card) => ( - - - - ))} - - ); -} - -// --- Leaderboard section ----------------------------------------------------- - -type LbMetric = "distance" | "sessions" | "duration" | "expenses"; - -function AnalyticsLeaderboard({ from, to }: { from?: string; to?: string }) { - const [metric, setMetric] = useState("distance"); - const { data, isLoading, error } = useLeaderboard(metric, 10, from, to); - - return ( - - - - - Employee Leaderboard - - setMetric(v as LbMetric)}> - - Distance - Sessions - Duration - Expenses - - - - - {error && } - {isLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : ( - - )} -
-
- ); -} - -// --- Animated range transition wrapper --------------------------------------- - -function RangeContent({ - rangeKey, - children, -}: { - rangeKey: string; - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} - -// --- Page -------------------------------------------------------------------- - -export default function AnalyticsPage() { - const { permissions } = useAuth(); - const router = useRouter(); - - // Lazy-init from localStorage — no flicker, no useEffect needed - const [preset, setPreset] = useState(() => loadPersistedPreset()); - const [customRange, setCustomRange] = useState( - () => loadPersistedCustomRange() - ); - - const activeRange = useMemo(() => { - if (preset === "custom" && customRange) return customRange; - if (preset === "custom") return rangeForPreset("7d"); - return rangeForPreset(preset); - }, [preset, customRange]); - - const { from, to } = activeRange; - const rangeKey = `${from}::${to}`; - - const summary = useOrgSummary(from, to); - const sessionTrend = useSessionTrend(from, to); - const topByDistance = useTopPerformers("distance", 10, from, to); - const topBySessions = useTopPerformers("sessions", 10, from, to); - - if (!permissions.viewAnalytics) { - router.replace("/sessions"); - return null; - } - - function handleFilterChange(newPreset: PresetKey, newCustom?: DateRange) { - setPreset(newPreset); - persistPreset(newPreset); - if (newCustom) { - setCustomRange(newCustom); - persistCustomRange(newCustom); - } - } - - return ( -
- {/* Header */} -
-

Analytics

-

- Historical performance insights — select a date range to explore trends. -

-
- - {/* Date range filter */} -
- -
- - {/* Animated content — remounts with fade on range change */} - -
- {summary.error && } - - {/* Summary cards */} - - - {/* Session trend chart */} - - - - - - Session Trend - - - - {sessionTrend.isLoading ? ( - - ) : sessionTrend.error ? ( - - ) : ( - - )} - - - - - {/* Top performers */} - -
- - - Top by Distance - - - {topByDistance.isLoading ? ( - - ) : topByDistance.error ? ( - - ) : ( - - )} - - - - - - Top by Sessions - - - {topBySessions.isLoading ? ( - - ) : topBySessions.error ? ( - - ) : ( - - )} - - -
-
- - {/* Leaderboard */} - - - -
-
-
- ); -} diff --git a/apps/web/src/app/(protected)/admin/employees/[id]/profile/page.tsx b/apps/web/src/app/(protected)/admin/employees/[id]/profile/page.tsx deleted file mode 100644 index 7ae4f0a..0000000 --- a/apps/web/src/app/(protected)/admin/employees/[id]/profile/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { useParams } from "next/navigation"; -import { useEmployeeProfile } from "@/hooks/queries/useProfile"; -import { useLeaderboard } from "@/hooks/queries/useAnalytics"; -import { useAuth } from "@/hooks/useAuth"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Skeleton } from "@/components/ui/skeleton"; -import { ProfileView } from "@/components/ProfileView"; -import { redirect } from "next/navigation"; - -export default function AdminEmployeeProfilePage() { - const { permissions } = useAuth(); - const params = useParams(); - const employeeId = params.id as string; - - if (!permissions.viewAnalytics) { - redirect("/profile"); - } - - const { data: profile, isLoading: profileLoading, error } = useEmployeeProfile(employeeId); - const { data: leaderboard } = useLeaderboard("distance", 50); - - const employeeRank = profile && leaderboard - ? leaderboard.find((e) => e.employeeId === profile.id)?.rank - : undefined; - - return ( -
-
-

Employee Profile

-

- Employee identity, performance, and activity status. -

-
- - {profileLoading ? ( -
- -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
- ) : error ? ( - - ) : profile ? ( - - ) : null} -
- ); -} - diff --git a/apps/web/src/app/(protected)/admin/employees/page.tsx b/apps/web/src/app/(protected)/admin/employees/page.tsx deleted file mode 100644 index 4ad0c88..0000000 --- a/apps/web/src/app/(protected)/admin/employees/page.tsx +++ /dev/null @@ -1,276 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { - useEmployeeList, - useCreateEmployee, - useSetEmployeeStatus, - type EmployeeRecord, -} from "@/hooks/queries/useEmployees"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { useToast } from "@/components/ui/use-toast"; -import { UserPlus, Search, UserCheck, UserX } from "lucide-react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { EmptyState } from "@/components/EmptyState"; -import { Users } from "lucide-react"; - -const PAGE_SIZE = 50; - -function EmployeeRow({ employee }: { employee: EmployeeRecord }) { - const { toast } = useToast(); - const setStatus = useSetEmployeeStatus(employee.id); - - function handleToggle() { - setStatus.mutate(!employee.is_active, { - onSuccess: () => - toast({ title: employee.is_active ? "Employee deactivated" : "Employee activated" }), - onError: (err) => - toast({ variant: "destructive", title: "Update failed", description: err.message }), - }); - } - - return ( - - {employee.employee_code} - {employee.name} - {employee.phone ?? "—"} - - - {employee.is_active ? "Active" : "Inactive"} - - - - - - - ); -} - -export default function AdminEmployeesPage() { - const { permissions } = useAuth(); - const router = useRouter(); - const { toast } = useToast(); - - useEffect(() => { - if (!permissions.viewAnalytics) { - router.replace("/sessions"); - } - }, [permissions, router]); - - const [page, setPage] = useState(1); - const [search, setSearch] = useState(""); - const [filterActive, setFilterActive] = useState(undefined); - const [showCreateForm, setShowCreateForm] = useState(false); - const [newName, setNewName] = useState(""); - const [newPhone, setNewPhone] = useState(""); - - const { data, isLoading, error } = useEmployeeList(page, PAGE_SIZE, { - active: filterActive, - search: search || undefined, - }); - const createEmployee = useCreateEmployee(); - - const employees = data?.data ?? []; - const total = data?.pagination.total ?? 0; - const hasMore = page * PAGE_SIZE < total; - - function handleCreate() { - if (!newName.trim()) return; - createEmployee.mutate( - { name: newName.trim(), phone: newPhone.trim() || undefined }, - { - onSuccess: (emp) => { - toast({ - title: "Employee created", - description: `${emp.name} — code: ${emp.employee_code}`, - }); - setNewName(""); - setNewPhone(""); - setShowCreateForm(false); - }, - onError: (err) => { - toast({ variant: "destructive", title: "Creation failed", description: err.message }); - }, - }, - ); - } - - if (!permissions.viewAnalytics) return null; - - return ( -
-
-
-

Employees

-

{total} employees registered

-
- -
- - {showCreateForm && ( - - - New Employee - - -
- - setNewName(e.target.value)} - /> -
-
- - setNewPhone(e.target.value)} - /> -
-
- - -
-
-
- )} - - - -
- - { - setSearch(e.target.value); - setPage(1); - }} - /> -
-
- {(["all", "active", "inactive"] as const).map((f) => ( - - ))} -
-
-
- - {error && } - - - - {isLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - - - -
- ))} -
- ) : employees.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - {employees.map((emp) => ( - - ))} - -
CodeNamePhoneStatusActions
-
- )} -
-
- - {(page > 1 || hasMore) && ( -
- - - Page {page} · {total} total - - -
- )} -
- ); -} diff --git a/apps/web/src/app/(protected)/admin/expenses/page.tsx b/apps/web/src/app/(protected)/admin/expenses/page.tsx deleted file mode 100644 index 59bb8b3..0000000 --- a/apps/web/src/app/(protected)/admin/expenses/page.tsx +++ /dev/null @@ -1,373 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { useExpenseSummaryByEmployee, useEmployeeOrgExpenses, useUpdateExpenseStatus } from "@/hooks/queries/useExpenses"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { EmployeeIdentity } from "@/components/EmployeeIdentity"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { useToast } from "@/components/ui/use-toast"; -import { Expense, ExpenseStatus, EmployeeExpenseSummary } from "@/types"; -import { formatCurrency, formatDate, cn } from "@/lib/utils"; -import { Receipt, ChevronRight, CheckCircle2, XCircle, ExternalLink } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const PAGE_SIZE = 50; - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function ExpenseStatusBadge({ status }: { status: ExpenseStatus }) { - if (status === "APPROVED") - return Approved; - if (status === "REJECTED") - return Rejected; - return Pending; -} - -function ExpenseReviewSheet({ - summary, - onClose, - onAction, - isPending, -}: { - summary: EmployeeExpenseSummary | null; - onClose: () => void; - onAction: (expense: Expense, status: ExpenseStatus) => void; - isPending: boolean; -}) { - // Load individual expenses for the selected employee on-demand. - // This avoids the bulk-fetch-all-expenses pattern — only this employee's - // expenses are loaded when the review sheet opens. - const { data: expensesPage, isLoading: expensesLoading } = useEmployeeOrgExpenses( - summary?.employeeId ?? null, - 1, - 100, - ); - const expenses = expensesPage?.data ?? []; - - return ( - !open && onClose()}> - - {summary && ( - <> - - Expense Review - -

- {summary.pendingCount > 0 - ? `${summary.pendingCount} pending · ${formatCurrency(summary.pendingAmount)}` - : "No pending expenses"} -

-
- -
- {expensesLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
- ) : ( - expenses.map((expense) => ( -
- {/* Header row */} -
-
-

{expense.description}

-

- Submitted {formatDate(expense.submitted_at)} - {expense.reviewed_at && ` · Reviewed ${formatDate(expense.reviewed_at)}`} -

-
-
-

- {formatCurrency(expense.amount)} -

- -
-
- - {/* Receipt link */} - {expense.receipt_url && ( - - - View receipt - - )} - - {/* Approve / Reject actions */} - {expense.status === "PENDING" && ( -
- - -
- )} -
- )) - )} -
- - )} - - - ); -} - -function EmployeeExpenseRow({ - group, - onClick, -}: { - group: EmployeeExpenseSummary; - onClick: () => void; -}) { - const hasPending = group.pendingCount > 0; - return ( - -
- -
- -
- {hasPending ? ( - {group.pendingCount} Pending - ) : ( - - )} -
- -
- {group.pendingAmount > 0 ? ( - formatCurrency(group.pendingAmount) - ) : ( - - )} -
- -
- {group.latestExpenseDate ? formatDate(group.latestExpenseDate) : "—"} -
- - -
- ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function AdminExpensesPage() { - const { permissions } = useAuth(); - const router = useRouter(); - const { toast } = useToast(); - - const [selectedSummary, setSelectedSummary] = useState(null); - const [viewPage, setViewPage] = useState(1); - - useEffect(() => { - if (!permissions.manageExpenses) router.replace("/sessions"); - }, [permissions, router]); - - // Server-aggregated: one row per employee, already sorted with pending-first. - // O(employees) vs O(all expenses) — no client-side grouping required. - const { data: summaryPage, isLoading, error, refetch } = useExpenseSummaryByEmployee(viewPage, PAGE_SIZE); - const updateStatus = useUpdateExpenseStatus(); - - const groups = summaryPage?.data ?? []; - const totalGroups = summaryPage?.pagination.total ?? 0; - const hasMore = groups.length > 0 && totalGroups > viewPage * PAGE_SIZE; - const pendingEmployees = groups.filter((g) => g.pendingCount > 0).length; - - // Keep the open sheet in sync: re-fetch on mutation success is handled by - // useUpdateExpenseStatus's onSuccess cache invalidation. - const refreshedSummary = selectedSummary - ? (groups.find((g) => g.employeeId === selectedSummary.employeeId) ?? selectedSummary) - : null; - - if (!permissions.manageExpenses) return null; - - function handleAction(expense: Expense, status: ExpenseStatus) { - updateStatus.mutate( - { id: expense.id, status }, - { - onSuccess: () => { - toast({ - title: status === "APPROVED" ? "Expense approved" : "Expense rejected", - description: `${expense.description} · ${formatCurrency(expense.amount)}`, - }); - }, - onError: (err) => { - toast({ - variant: "destructive", - title: "Failed to update", - description: err.message, - }); - }, - } - ); - } - - return ( -
- {/* Header */} -
-
-

Manage Expenses

-

- {isLoading - ? "Loading..." - : `${totalGroups} employees · ${ - pendingEmployees > 0 - ? `${pendingEmployees} require${pendingEmployees === 1 ? "s" : ""} attention` - : "all clear" - }`} -

-
- {pendingEmployees > 0 && !isLoading && ( - - {pendingEmployees} pending - - )} -
- - {error && void refetch()} />} - - {/* Table */} -
- {/* Column headers */} -
- Employee - Pending Expenses - Pending Amount - Latest Expense - -
- - {/* Skeleton */} - {isLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
-
- {Array.from({ length: 3 }).map((_, j) => ( -
- ))} -
-
- ))} -
- )} - - {/* Empty */} - {!isLoading && groups.length === 0 && ( -
- -

No expenses found

-
- )} - - {/* Rows */} - {!isLoading && groups.length > 0 && ( -
- - {groups.map((group) => ( - setSelectedSummary(group)} - /> - ))} - - - {hasMore && ( -
- -
- )} -
- )} -
- - {/* Expense review slide-in panel */} - setSelectedSummary(null)} - onAction={handleAction} - isPending={updateStatus.isPending} - /> -
- ); -} diff --git a/apps/web/src/app/(protected)/admin/monitoring/map/EmployeeMap.tsx b/apps/web/src/app/(protected)/admin/monitoring/map/EmployeeMap.tsx deleted file mode 100644 index 8efbb8b..0000000 --- a/apps/web/src/app/(protected)/admin/monitoring/map/EmployeeMap.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client"; - -/** - * EmployeeMap — Leaflet map with MarkerClusterGroup support. - * - * Imported dynamically with `ssr: false` from the parent page because Leaflet - * accesses `window` at module initialisation time and will crash Next.js SSR. - * - * Marker colour scheme: - * ACTIVE → green (checked in within the last 2 hours) - * RECENT → orange (checked out, still this calendar day) - * INACTIVE → grey (no session activity today) - * - * Selected employee → enlarged SVG + pulsing ring overlay. - * Clustering → nearby markers grouped at low zoom via MarkerClusterGroup. - */ - -import { useEffect, useRef } from "react"; -import type { Map as LeafletMap, Marker as LeafletMarker } from "leaflet"; -import L from "leaflet"; -import "leaflet.markercluster"; -import type { EmployeeMapMarker } from "@/types"; - -// ─── Marker icon colours ────────────────────────────────────────────────────── - -const STATUS_COLOURS: Record = { - ACTIVE: "#22c55e", // green-500 - RECENT: "#f97316", // orange-500 - INACTIVE: "#94a3b8", // slate-400 -}; - -function makeIcon( - status: EmployeeMapMarker["status"], - selected = false -) { - const colour = STATUS_COLOURS[status]; - const size = selected ? 32 : 24; - const inner = selected ? 8 : 5; - - const pulse = selected - ? ` - - - ` - : ""; - - const svg = ` - - ${pulse} - - - - `.trim(); - - return L.divIcon({ - html: svg, - className: "", // prevent Leaflet's default white-box class - iconSize: [size, size], - iconAnchor: [size / 2, size / 2], - popupAnchor: [0, -(size / 2 + 2)], - }); -} - -// ─── Popup HTML ─────────────────────────────────────────────────────────────── - -function buildPopupHtml(m: EmployeeMapMarker): string { - const ts = new Date(m.recordedAt).toLocaleString(); - const code = m.employeeCode ? ` (${m.employeeCode})` : ""; - const statusColour = - m.status === "ACTIVE" ? "#22c55e" : - m.status === "RECENT" ? "#f97316" : "#94a3b8"; - - return ` -
- ${m.employeeName}${code}
- ${m.status}
- Last fix: ${ts} -
- `.trim(); -} - -// ─── Component ──────────────────────────────────────────────────────────────── - -interface Props { - markers: EmployeeMapMarker[]; - isLoading: boolean; - selectedEmployeeId?: string | null; -} - -export default function EmployeeMap({ markers, isLoading, selectedEmployeeId }: Props) { - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const clusterGroupRef = useRef(null); - // Track current markers by employeeId → Leaflet marker - const markerMapRef = useRef>(new Map()); - - // ── Initialise Leaflet map once ──────────────────────────────────────────── - useEffect(() => { - if (!mapContainerRef.current || mapRef.current) return; - const container = mapContainerRef.current; - - const map = L.map(container, { - center: [20, 0], - zoom: 2, - zoomControl: true, - }); - - L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: - '© OpenStreetMap contributors', - maxZoom: 19, - }).addTo(map); - - // Marker cluster group with custom cluster icon - const clusterGroup = L.markerClusterGroup({ - maxClusterRadius: 60, - showCoverageOnHover: false, - iconCreateFunction(cluster) { - const count = cluster.getChildCount(); - return L.divIcon({ - html: `
${count}
`, - className: "", - iconSize: [36, 36], - iconAnchor: [18, 18], - }); - }, - }); - map.addLayer(clusterGroup); - clusterGroupRef.current = clusterGroup; - mapRef.current = map; - - // ResizeObserver → invalidateSize when container dimensions change - const ro = new ResizeObserver(() => map.invalidateSize({ animate: false })); - ro.observe(container); - - const raf = requestAnimationFrame(() => map.invalidateSize({ animate: false })); - - return () => { - ro.disconnect(); - cancelAnimationFrame(raf); - map.remove(); - mapRef.current = null; - clusterGroupRef.current = null; - markerMapRef.current.clear(); - }; - }, []); - - // ── Sync markers when data or selection changes ──────────────────────────── - useEffect(() => { - const map = mapRef.current; - const clusterGroup = clusterGroupRef.current; - if (!map || !clusterGroup) return; - - const incoming = new Map(markers.map((m) => [m.employeeId, m])); - const existing = markerMapRef.current; - - // Remove markers no longer in the data set - for (const [id, leafletMarker] of existing) { - if (!incoming.has(id)) { - clusterGroup.removeLayer(leafletMarker); - existing.delete(id); - } - } - - const latLngs: [number, number][] = []; - const toAdd: LeafletMarker[] = []; - - for (const m of markers) { - const isSelected = selectedEmployeeId === m.employeeId; - const icon = makeIcon(m.status, isSelected); - latLngs.push([m.latitude, m.longitude]); - - if (existing.has(m.employeeId)) { - // Update existing marker position + icon (smooth move, no remove/re-add) - const lm = existing.get(m.employeeId)!; - lm.setLatLng([m.latitude, m.longitude]); - lm.setIcon(icon); - lm.setPopupContent(buildPopupHtml(m)); - } else { - // New marker - const lm = L.marker([m.latitude, m.longitude], { icon }).bindPopup(buildPopupHtml(m)); - existing.set(m.employeeId, lm); - toAdd.push(lm); - } - } - - if (toAdd.length > 0) { - clusterGroup.addLayers(toAdd); - } - - markerMapRef.current = existing; - - // Auto-centre: only on first data load (when no markers existed before) - if (latLngs.length > 0 && existing.size === toAdd.length) { - map.fitBounds(L.latLngBounds(latLngs), { padding: [40, 40], maxZoom: 14 }); - } - - // Pan to selected employee marker if it exists - if (selectedEmployeeId) { - const sel = existing.get(selectedEmployeeId); - if (sel) { - map.setView(sel.getLatLng(), Math.max(map.getZoom(), 13), { animate: true }); - sel.openPopup(); - } - } - }, [markers, selectedEmployeeId]); - - return ( -
- {isLoading && ( -
- Loading positions… -
- )} -
-
- ); -} diff --git a/apps/web/src/app/(protected)/admin/monitoring/map/page.tsx b/apps/web/src/app/(protected)/admin/monitoring/map/page.tsx deleted file mode 100644 index f78477b..0000000 --- a/apps/web/src/app/(protected)/admin/monitoring/map/page.tsx +++ /dev/null @@ -1,268 +0,0 @@ -"use client"; - -import { useState } from "react"; -import dynamic from "next/dynamic"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { useAdminMap } from "@/hooks/queries/useDashboard"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { MapPin, RefreshCw, Users, Search } from "lucide-react"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import type { EmployeeMapMarker } from "@/types"; - -// ─── Dynamic Leaflet import (SSR disabled — Leaflet uses `window`) ──────────── - -const EmployeeMap = dynamic(() => import("./EmployeeMap"), { - ssr: false, - loading: () => ( -
- Loading map… -
- ), -}); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -const STATUS_DOT: Record = { - ACTIVE: "bg-emerald-500", - RECENT: "bg-orange-400", - INACTIVE: "bg-slate-400", -}; - -const STATUS_LABEL: Record = { - ACTIVE: "Active", - RECENT: "Recent", - INACTIVE: "Inactive", -}; - -// ─── Employee List Item ──────────────────────────────────────────────────────── - -function EmployeeListItem({ - marker, - selected, - onClick, -}: { - marker: EmployeeMapMarker; - selected: boolean; - onClick: () => void; -}) { - const initials = marker.employeeName - .split(" ") - .slice(0, 2) - .map((n) => n[0] ?? "") - .join("") - .toUpperCase(); - - return ( - - ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function MonitoringMapPage() { - const { permissions } = useAuth(); - const router = useRouter(); - const [selectedId, setSelectedId] = useState(null); - const [search, setSearch] = useState(""); - - useEffect(() => { - if (!permissions.viewAnalytics) { - router.replace("/sessions"); - } - }, [permissions, router]); - - const { data: markers = [], isLoading, error, dataUpdatedAt, refetch } = useAdminMap(); - - if (!permissions.viewAnalytics) return null; - - const activeCount = markers.filter((m) => m.status === "ACTIVE").length; - const recentCount = markers.filter((m) => m.status === "RECENT").length; - const inactiveCount = markers.filter((m) => m.status === "INACTIVE").length; - - const filtered = markers.filter((m) => - search - ? m.employeeName.toLowerCase().includes(search.toLowerCase()) || - (m.employeeCode ?? "").toLowerCase().includes(search.toLowerCase()) - : true - ); - - function handleSelect(id: string) { - setSelectedId((prev) => (prev === id ? null : id)); - } - - return ( -
- {/* Header */} -
-
-

Live Employee Map

-

- Showing latest GPS position per employee. Refreshes every 30 s. -

-
-
- {dataUpdatedAt ? ( - - Updated {new Date(dataUpdatedAt).toLocaleTimeString()} - - ) : null} - -
-
- - {/* Summary badges */} -
- - - {activeCount} Active - - - - {recentCount} Recent - - - - {markers.length} on map - -
- - {/* Error */} - {error ? : null} - - {/* Main content: map + employee list */} -
- {/* Map */} - - - Employee Positions - - -
- -
-
-
- - {/* Employee sidebar */} - - - - - Employees - - - - {/* Search */} -
- - setSearch(e.target.value)} - className="pl-8 h-8 text-sm" - /> -
- - {/* Status summary */} -
- {activeCount} active - {recentCount} recent - {inactiveCount} inactive -
- - {/* Scrollable list */} -
- {isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
- ))} -
- )} - - {!isLoading && filtered.length === 0 && ( -

No employees found

- )} - - {/* Sort: ACTIVE first, then RECENT, then INACTIVE */} - {[...filtered] - .sort((a, b) => { - const order = { ACTIVE: 0, RECENT: 1, INACTIVE: 2 }; - return order[a.status] - order[b.status]; - }) - .map((m) => ( - handleSelect(m.employeeId)} - /> - ))} -
- - -
- - {/* Empty state */} - {!isLoading && markers.length === 0 && !error && ( -
- -

No GPS data yet

-

- Markers appear after employees check in and record a location point. -

-
- )} -
- ); -} diff --git a/apps/web/src/app/(protected)/admin/monitoring/page.tsx b/apps/web/src/app/(protected)/admin/monitoring/page.tsx deleted file mode 100644 index c55c25f..0000000 --- a/apps/web/src/app/(protected)/admin/monitoring/page.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { - useMonitoringHistory, - useStartMonitoring, - useStopMonitoring, -} from "@/hooks/queries/useMonitoring"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { useToast } from "@/components/ui/use-toast"; -import { formatDate, formatTime, formatDuration } from "@/lib/utils"; -import { Play, Square, Activity } from "lucide-react"; -import type { AdminSession } from "@/types"; - -const PAGE_LIMIT = 20; - -function formatSessionDuration(session: AdminSession): string { - if (!session.ended_at) return "Ongoing"; - const startMs = new Date(session.started_at).getTime(); - const endMs = new Date(session.ended_at).getTime(); - const seconds = Math.round((endMs - startMs) / 1000); - return formatDuration(seconds); -} - -export default function AdminMonitoringPage() { - const { permissions } = useAuth(); - const router = useRouter(); - const { toast } = useToast(); - - useEffect(() => { - if (!permissions.viewAnalytics) { - router.replace("/sessions"); - } - }, [permissions, router]); - - const [page, setPage] = useState(1); - const { data, isLoading, error } = useMonitoringHistory(page, PAGE_LIMIT); - const startMonitoring = useStartMonitoring(); - const stopMonitoring = useStopMonitoring(); - - const sessions = data?.data ?? []; - const total = data?.pagination.total ?? 0; - const hasMore = page * PAGE_LIMIT < total; - - const activeSession = sessions.find((s) => !s.ended_at); - - if (!permissions.viewAnalytics) return null; - - function handleStart() { - startMonitoring.mutate(undefined, { - onSuccess: () => { - toast({ title: "Monitoring started", description: "Location monitoring is now active." }); - }, - onError: (err) => { - toast({ variant: "destructive", title: "Failed to start", description: err.message }); - }, - }); - } - - function handleStop() { - stopMonitoring.mutate(undefined, { - onSuccess: () => { - toast({ title: "Monitoring stopped", description: "Location monitoring has been stopped." }); - }, - onError: (err) => { - const isNotFound = (err as { status?: number }).status === 404; - const msg = isNotFound ? "No active session to stop." : err.message; - toast({ variant: "destructive", title: "Failed to stop", description: msg }); - }, - }); - } - - return ( -
-
-

Monitoring

-

Control and review location monitoring sessions.

-
- - {/* Controls */} - - - - - Monitoring Controls - - - - {activeSession ? ( - - Active since {formatTime(activeSession.started_at)} - - ) : ( - Inactive - )} - - - - - - {/* History */} -
-

Session History

- - {error && } - - {isLoading ? ( -

Loading...

- ) : sessions.length === 0 ? ( -

No monitoring sessions yet.

- ) : ( -
- - - - - - - - - - - {sessions.map((session) => ( - - - - - - - ))} - -
Start TimeEnd TimeDurationStatus
-
{formatDate(session.started_at)}
-
{formatTime(session.started_at)}
-
- {session.ended_at ? ( - <> -
{formatDate(session.ended_at)}
-
{formatTime(session.ended_at)}
- - ) : ( - - )} -
{formatSessionDuration(session)} - {session.ended_at ? ( - Ended - ) : ( - Active - )} -
-
- )} - - {(sessions.length > 0) && ( -
- - Page {page} - -
- )} -
-
- ); -} diff --git a/apps/web/src/app/(protected)/admin/queues/page.tsx b/apps/web/src/app/(protected)/admin/queues/page.tsx deleted file mode 100644 index b511543..0000000 --- a/apps/web/src/app/(protected)/admin/queues/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { useAdminQueues, type QueueStats } from "@/hooks/queries/useQueues"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Activity } from "lucide-react"; - -function StatRow({ label, value }: { label: string; value: number }) { - return ( -
- {label} - {value.toLocaleString()} -
- ); -} - -function QueueCard({ name, stats }: { name: string; stats: QueueStats }) { - const hasBacklog = stats.waiting > 100 || stats.failed > 5; - return ( - - -
- {name} Queue - - {hasBacklog ? "Attention" : "Healthy"} - -
-
- - - - - - {stats.dlq !== undefined && } - -
- ); -} - -export default function AdminQueuesPage() { - const { permissions } = useAuth(); - const router = useRouter(); - - useEffect(() => { - if (!permissions.viewAnalytics) { - router.replace("/sessions"); - } - }, [permissions, router]); - - const { data, isLoading, error } = useAdminQueues(); - - if (!permissions.viewAnalytics) return null; - - return ( -
-
- -
-

Queue Monitor

-

- BullMQ — refreshes every 30 s -

-
-
- - {error && } - - {isLoading ? ( -

Loading queue stats…

- ) : data ? ( -
- - -
- ) : null} -
- ); -} diff --git a/apps/web/src/app/(protected)/admin/sessions/[id]/locations/page.tsx b/apps/web/src/app/(protected)/admin/sessions/[id]/locations/page.tsx deleted file mode 100644 index ce743b2..0000000 --- a/apps/web/src/app/(protected)/admin/sessions/[id]/locations/page.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter, useParams } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { useSessionLocations } from "@/hooks/queries/useSessionLocations"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ArrowLeft, MapPin } from "lucide-react"; -import { formatDate } from "@/lib/utils"; - -function formatCoord(n: number, decimals = 6) { - return n.toFixed(decimals); -} - -export default function SessionLocationsPage() { - const { permissions } = useAuth(); - const router = useRouter(); - const params = useParams<{ id: string }>(); - const sessionId = params.id ?? null; - - useEffect(() => { - if (!permissions.viewAnalytics) { - router.replace("/sessions"); - } - }, [permissions, router]); - - const { data: locations, isLoading, error } = useSessionLocations(sessionId); - - if (!permissions.viewAnalytics) return null; - - return ( -
-
- -
-

Session Route

-

- {sessionId} -

-
-
- - {error && } - - - -
- - - GPS Points - - {!isLoading && locations && ( - {locations.length} point{locations.length !== 1 ? "s" : ""} - )} -
-
- - {isLoading ? ( -
Loading route…
- ) : !locations || locations.length === 0 ? ( -
No GPS points recorded for this session.
- ) : ( -
- - - - - - - - - - - - {locations.map((loc, idx) => ( - - - - - - - - ))} - -
#Recorded AtLatitudeLongitudeAccuracy (m)
- {loc.sequence_number ?? idx + 1} - - {formatDate(loc.recorded_at)} - - {formatCoord(loc.latitude)} - - {formatCoord(loc.longitude)} - - {loc.accuracy !== null ? loc.accuracy.toFixed(1) : "—"} -
-
- )} -
-
-
- ); -} diff --git a/apps/web/src/app/(protected)/admin/sessions/page.tsx b/apps/web/src/app/(protected)/admin/sessions/page.tsx deleted file mode 100644 index 68f4bf7..0000000 --- a/apps/web/src/app/(protected)/admin/sessions/page.tsx +++ /dev/null @@ -1,416 +0,0 @@ -"use client"; - -import { useEffect, useState, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { useAuth } from "@/hooks/useAuth"; -import { useAllOrgSessions, useEmployeeSessionHistory } from "@/hooks/queries/useSessions"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { EmployeeIdentity } from "@/components/EmployeeIdentity"; -import { Badge } from "@/components/ui/badge"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { AttendanceSession, ActivityStatus } from "@/types"; -import { formatDate, formatTime, formatDistance, formatDuration, cn } from "@/lib/utils"; -import { Clock, ChevronRight } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; - -// --- Constants ---------------------------------------------------------------- - -const VIEW_PAGE_SIZE = 25; - -// --- Types -------------------------------------------------------------------- - -type FilterTab = "all" | ActivityStatus; - -const TABS: { key: FilterTab; label: string }[] = [ - { key: "all", label: "All" }, - { key: "ACTIVE", label: "Active" }, - { key: "RECENT", label: "Recently Active" }, - { key: "INACTIVE", label: "Inactive" }, -]; - -interface EmployeeSessionGroup { - employeeId: string; - employeeName: string; - employeeCode: string | null; - activityStatus: ActivityStatus; - latestSession: AttendanceSession; - sessions: AttendanceSession[]; -} - -// --- Helpers ------------------------------------------------------------------ - -function deriveStatus(session: AttendanceSession): ActivityStatus { - if (!session.checkout_at) return "ACTIVE"; - const lastTs = new Date(session.checkout_at).getTime(); - return Date.now() - lastTs < 86_400_000 ? "RECENT" : "INACTIVE"; -} - -const STATUS_ORDER: Record = { ACTIVE: 0, RECENT: 1, INACTIVE: 2 }; - -function groupSessions(sessions: AttendanceSession[]): EmployeeSessionGroup[] { - const map = new Map(); - for (const s of sessions) { - const arr = map.get(s.employee_id) ?? []; - arr.push(s); - map.set(s.employee_id, arr); - } - const groups: EmployeeSessionGroup[] = []; - for (const [empId, empSessions] of map) { - const sorted = [...empSessions].sort( - (a, b) => new Date(b.checkin_at).getTime() - new Date(a.checkin_at).getTime() - ); - const latest = sorted[0]; - const status = latest.activityStatus ?? deriveStatus(latest); - groups.push({ - employeeId: empId, - employeeName: latest.employee_name ?? latest.employee_code ?? empId, - employeeCode: latest.employee_code ?? null, - activityStatus: status, - latestSession: latest, - sessions: sorted, - }); - } - groups.sort((a, b) => { - const sd = STATUS_ORDER[a.activityStatus] - STATUS_ORDER[b.activityStatus]; - if (sd !== 0) return sd; - return ( - new Date(b.latestSession.checkin_at).getTime() - - new Date(a.latestSession.checkin_at).getTime() - ); - }); - return groups; -} - -// --- Sub-components ----------------------------------------------------------- - -function StatusBadge({ status }: { status: ActivityStatus }) { - if (status === "ACTIVE") - return ( - - Active - - ); - if (status === "RECENT") - return ( - - Recent - - ); - return ( - - Inactive - - ); -} - -function SessionHistorySheet({ - group, - onClose, -}: { - group: EmployeeSessionGroup | null; - onClose: () => void; -}) { - const { data: historyPage, isLoading: historyLoading } = useEmployeeSessionHistory( - group?.employeeId ?? null, - ); - const historySessions = historyPage?.data ?? []; - - return ( - !open && onClose()}> - - {group && ( - <> - - Session History - -

- {historyLoading - ? "Loading sessions…" - : `${historySessions.length} session${historySessions.length !== 1 ? "s" : ""} total`} -

-
- -
- {historyLoading && - Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
- ))} - {!historyLoading && - historySessions.map((session) => { - const status = session.activityStatus ?? deriveStatus(session); - return ( -
-
-
-
- {formatDate(session.checkin_at)} - -
-
- - - {formatTime(session.checkin_at)} - {session.checkout_at - ? ` to ${formatTime(session.checkout_at)}` - : " (checked in)"} - -
-
-
-

- {formatDistance(session.total_distance_km)} -

-

- {formatDuration(session.total_duration_seconds)} -

-
-
-
- ); - })} -
- - )} - - - ); -} - -function EmployeeSessionRow({ - group, - onClick, -}: { - group: EmployeeSessionGroup; - onClick: () => void; -}) { - const s = group.latestSession; - return ( - -
- -
- -
- {formatDate(s.checkin_at)} - {formatTime(s.checkin_at)} -
- -
- {s.checkout_at ? ( - <> - {formatDate(s.checkout_at)} - {formatTime(s.checkout_at)} - - ) : ( - - - Live - - )} -
- - {formatDistance(s.total_distance_km)} - {formatDuration(s.total_duration_seconds)} - -
- -
- - -
- ); -} - -// --- Page --------------------------------------------------------------------- - -export default function AdminSessionsPage() { - const { permissions } = useAuth(); - const router = useRouter(); - - const [activeTab, setActiveTab] = useState("all"); - const [selectedGroup, setSelectedGroup] = useState(null); - const [viewPage, setViewPage] = useState(1); - - useEffect(() => { - if (!permissions.viewOrgSessions) router.replace("/sessions"); - }, [permissions, router]); - - const { data: allSessions, isLoading, error, refetch } = useAllOrgSessions(); - const groups = useMemo(() => groupSessions(allSessions), [allSessions]); - - const tabCounts = useMemo(() => { - const counts: Record = { all: groups.length, ACTIVE: 0, RECENT: 0, INACTIVE: 0 }; - for (const g of groups) counts[g.activityStatus]++; - return counts; - }, [groups]); - - const filtered = useMemo( - () => (activeTab === "all" ? groups : groups.filter((g) => g.activityStatus === activeTab)), - [groups, activeTab] - ); - - const paged = filtered.slice(0, viewPage * VIEW_PAGE_SIZE); - const hasMore = paged.length < filtered.length; - - if (!permissions.viewOrgSessions) return null; - - return ( -
-
-

All Sessions

-

- {isLoading - ? "Loading..." - : `${groups.length} employee${groups.length !== 1 ? "s" : ""} with session activity`} -

-
- - {error && void refetch()} />} - -
- {TABS.map((tab) => ( - - ))} -
- -
-
- Employee - Latest Check-in - Latest Check-out - Distance - Duration - Status - -
- - {isLoading && ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, j) => ( -
- ))} -
-
- ))} -
- )} - - {!isLoading && filtered.length === 0 && ( -
- -

No sessions found

-

- {activeTab !== "all" - ? "Try switching to a different filter" - : "Sessions will appear here once recorded"} -

-
- )} - - {!isLoading && filtered.length > 0 && ( -
- - {paged.map((group) => ( - setSelectedGroup(group)} - /> - ))} - - - {hasMore && ( -
- -
- )} -
- )} -
- - setSelectedGroup(null)} - /> -
- ); -} \ No newline at end of file diff --git a/apps/web/src/app/(protected)/admin/webhooks/page.tsx b/apps/web/src/app/(protected)/admin/webhooks/page.tsx deleted file mode 100644 index 7e25147..0000000 --- a/apps/web/src/app/(protected)/admin/webhooks/page.tsx +++ /dev/null @@ -1,819 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { useToast } from "@/components/ui/use-toast"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetFooter, -} from "@/components/ui/sheet"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - Webhook, - Plus, - Trash2, - RefreshCw, - ChevronDown, - ChevronUp, - CheckCircle2, - XCircle, - Clock, - ToggleLeft, - ToggleRight, - Eye, - EyeOff, - Copy, - Check, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - useWebhooks, - useWebhookDeliveries, - useCreateWebhook, - useUpdateWebhook, - useDeleteWebhook, - useRetryDelivery, - WEBHOOK_EVENT_TYPES, - type WebhookRecord, - type WebhookDelivery, - type DeliveryStatus, - type CreateWebhookBody, -} from "@/hooks/queries/useWebhooks"; - -// ─── Constants ──────────────────────────────────────────────────────────────── - -const EVENT_LABELS: Record = { - "employee.checked_in": "Check In", - "employee.checked_out": "Check Out", - "expense.created": "Expense Created", - "expense.approved": "Expense Approved", - "expense.rejected": "Expense Rejected", - "employee.created": "Employee Created", -}; - -const STATUS_CONFIG: Record< - DeliveryStatus, - { label: string; icon: React.ElementType; className: string } -> = { - success: { label: "Success", icon: CheckCircle2, className: "text-emerald-500" }, - failed: { label: "Failed", icon: XCircle, className: "text-rose-500" }, - pending: { label: "Pending", icon: Clock, className: "text-amber-500" }, -}; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - if (diff < 60_000) return "Just now"; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return `${Math.floor(diff / 86_400_000)}d ago`; -} - -// ─── Delivery Status Badge ──────────────────────────────────────────────────── - -function DeliveryStatusBadge({ status }: { status: DeliveryStatus }) { - const { label, icon: Icon, className } = STATUS_CONFIG[status]; - return ( - - - {label} - - ); -} - -// ─── Expandable Payload Row ─────────────────────────────────────────────────── - -function DeliveryRow({ delivery, onRetry, isRetrying }: { - delivery: WebhookDelivery; - onRetry: (id: string) => void; - isRetrying: boolean; -}) { - const [expanded, setExpanded] = useState(false); - - return ( -
- - )} - {expanded ? ( - - ) : ( - - )} - - - - {expanded && ( - -
-
-

- Response Body -

-
-                  {delivery.response_body ?? "(no response body)"}
-                
-
-
- Event: {delivery.event_id.slice(0, 8)}… - Delivery: {delivery.id.slice(0, 8)}… -
-
-
- )} -
-
- ); -} - -// ─── Deliveries Panel ───────────────────────────────────────────────────────── - -function DeliveriesPanel({ webhookId }: { webhookId: string | null }) { - const [page, setPage] = useState(1); - const [statusFilter, setStatusFilter] = useState(undefined); - const retryDelivery = useRetryDelivery(); - const { toast } = useToast(); - - const { data, isLoading } = useWebhookDeliveries( - page, - 20, - webhookId ?? undefined, - statusFilter, - ); - - const deliveries = data?.data ?? []; - const total = data?.pagination.total ?? 0; - const hasMore = page * 20 < total; - - function handleRetry(id: string) { - retryDelivery.mutate(id, { - onSuccess: () => toast({ title: "Delivery queued for retry" }), - onError: (e) => toast({ variant: "destructive", title: "Retry failed", description: e.message }), - }); - } - - const FILTERS: { key: DeliveryStatus | undefined; label: string }[] = [ - { key: undefined, label: "All" }, - { key: "pending", label: "Pending" }, - { key: "success", label: "Success" }, - { key: "failed", label: "Failed" }, - ]; - - return ( -
- {/* Filters */} -
- {FILTERS.map((f) => ( - - ))} -
- -
- {isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- - - -
- ))} -
- )} - - {!isLoading && deliveries.length === 0 && ( -
- -

No deliveries yet

-

- Deliveries appear here when a webhook event is triggered. -

-
- )} - - {!isLoading && deliveries.length > 0 && ( -
- {deliveries.map((d) => ( - - ))} -
- )} -
- - {(deliveries.length > 0 || page > 1) && ( -
- {total} total deliveries -
- - -
-
- )} -
- ); -} - -// ─── Webhook Card ───────────────────────────────────────────────────────────── - -function WebhookCard({ - webhook, - onEdit, - onDelete, -}: { - webhook: WebhookRecord; - onEdit: (w: WebhookRecord) => void; - onDelete: (id: string) => void; -}) { - const [showDeliveries, setShowDeliveries] = useState(false); - const [copied, setCopied] = useState(false); - - const updateWebhook = useUpdateWebhook(webhook.id); - const { toast } = useToast(); - - function handleToggleActive() { - updateWebhook.mutate( - { is_active: !webhook.is_active }, - { - onSuccess: () => - toast({ title: `Webhook ${!webhook.is_active ? "enabled" : "disabled"}` }), - onError: (e) => - toast({ variant: "destructive", title: "Update failed", description: e.message }), - } - ); - } - - function copyUrl() { - void navigator.clipboard.writeText(webhook.url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( - - {/* Header row */} -
- {/* Status dot */} -
- {webhook.is_active ? ( - - - - - ) : ( - - )} -
- - {/* URL + events */} -
-
- - {webhook.url} - - -
-
- {webhook.events.map((e) => ( - - {EVENT_LABELS[e] ?? e} - - ))} -
-
- - {/* Actions */} -
- - - -
-
- - {/* Deliveries toggle */} - - - - {showDeliveries && ( - -
- -
-
- )} -
-
- ); -} - -// ─── Create / Edit Sheet ────────────────────────────────────────────────────── - -interface WebhookFormState { - url: string; - secret: string; - events: Set; -} - -function WebhookSheet({ - open, - editing, - onClose, -}: { - open: boolean; - editing: WebhookRecord | null; - onClose: () => void; -}) { - const { toast } = useToast(); - const createWebhook = useCreateWebhook(); - // Always call the hook — pass editing.id when editing, empty string otherwise. - // An empty string never triggers a real request (mutations are on-demand). - const updateWebhook = useUpdateWebhook(editing?.id ?? ""); - const [showSecret, setShowSecret] = useState(false); - const [form, setForm] = useState({ - url: "", - secret: "", - events: new Set(), - }); - - // Sync form when the editing target changes - // eslint-disable-next-line react-hooks/exhaustive-deps - useState(() => { - if (editing) { - setForm({ url: editing.url, secret: "", events: new Set(editing.events) }); - } else { - setForm({ url: "", secret: "", events: new Set() }); - } - }); - - function handleOpen(isOpen: boolean) { - if (!isOpen) { - setForm({ url: "", secret: "", events: new Set() }); - onClose(); - } - } - - function toggleEvent(event: string) { - setForm((f) => { - const next = new Set(f.events); - next.has(event) ? next.delete(event) : next.add(event); - return { ...f, events: next }; - }); - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - - if (form.events.size === 0) { - toast({ variant: "destructive", title: "Select at least one event" }); - return; - } - - if (editing) { - const patch: Parameters[0] = { - url: form.url || editing.url, - events: [...form.events] as CreateWebhookBody["events"], - }; - if (form.secret) patch.secret = form.secret; - - updateWebhook.mutate(patch, { - onSuccess: () => { toast({ title: "Webhook updated" }); onClose(); }, - onError: (err) => toast({ variant: "destructive", title: "Update failed", description: err.message }), - }); - } else { - if (form.url.length < 5) { - toast({ variant: "destructive", title: "Enter a valid URL" }); - return; - } - if (form.secret.length < 16) { - toast({ variant: "destructive", title: "Secret must be ≥ 16 characters" }); - return; - } - createWebhook.mutate( - { - url: form.url, - secret: form.secret, - events: [...form.events] as CreateWebhookBody["events"], - }, - { - onSuccess: () => { toast({ title: "Webhook registered" }); onClose(); }, - onError: (err) => toast({ variant: "destructive", title: "Failed to create webhook", description: err.message }), - } - ); - } - } - - const isPending = createWebhook.isPending || updateWebhook.isPending; - - return ( - - - - - - {editing ? "Edit Webhook" : "Register Webhook"} - - - -
-
- {/* URL */} -
- - setForm((f) => ({ ...f, url: e.target.value }))} - required={!editing} - /> -

- FieldTrack will POST JSON events to this URL. -

-
- - {/* Secret */} -
- -
- setForm((f) => ({ ...f, secret: e.target.value }))} - required={!editing} - className="pr-10" - /> - -
-

- Used to sign the X-FieldTrack-Signature header. -

-
- - {/* Events */} -
- -
- {WEBHOOK_EVENT_TYPES.map((event) => { - const checked = form.events.has(event); - return ( - - ); - })} -
-
-
- - - - - -
-
-
- ); -} - -// ─── Delete Confirm Dialog ──────────────────────────────────────────────────── - -function DeleteWebhookDialog({ - webhookId, - onClose, -}: { - webhookId: string | null; - onClose: () => void; -}) { - const deleteWebhook = useDeleteWebhook(); - const { toast } = useToast(); - - function handleConfirm() { - if (!webhookId) return; - deleteWebhook.mutate(webhookId, { - onSuccess: () => { toast({ title: "Webhook deleted" }); onClose(); }, - onError: (e) => { toast({ variant: "destructive", title: "Delete failed", description: e.message }); onClose(); }, - }); - } - - return ( - !open && onClose()}> - - - Delete webhook? - - This will permanently remove the webhook endpoint and all its delivery - history. This action cannot be undone. - - - - Cancel - - {deleteWebhook.isPending ? "Deleting…" : "Delete"} - - - - - ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function WebhooksPage() { - const { data: webhooks, isLoading, error } = useWebhooks(); - const [sheetOpen, setSheetOpen] = useState(false); - const [editingWebhook, setEditingWebhook] = useState(null); - const [deletingId, setDeletingId] = useState(null); - - function openCreate() { - setEditingWebhook(null); - setSheetOpen(true); - } - - function openEdit(w: WebhookRecord) { - setEditingWebhook(w); - setSheetOpen(true); - } - - function closeSheet() { - setSheetOpen(false); - setEditingWebhook(null); - } - - return ( -
- {/* Page header */} -
-
-

- - Webhooks -

-

- Register HTTP endpoints to receive real-time FieldTrack events. -

-
- -
- - {/* Error state */} - {error && ( -
- Failed to load webhooks: {error.message} -
- )} - - {/* Loading state */} - {isLoading && ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- - - -
-
- - -
-
- ))} -
- )} - - {/* Empty state */} - {!isLoading && !error && (webhooks ?? []).length === 0 && ( -
-
- -
-
-

No webhooks registered

-

- Register an HTTP endpoint to receive real-time events like check-ins, - expense submissions, and employee updates. -

-
- -
- )} - - {/* Webhook cards */} - {!isLoading && (webhooks ?? []).length > 0 && ( - <> - {/* Summary bar */} -
- - {webhooks!.length}{" "} - webhook{webhooks!.length !== 1 ? "s" : ""} - - · - - - {webhooks!.filter((w) => w.is_active).length} - {" "} - active - -
- - -
- {webhooks!.map((webhook) => ( - - ))} -
-
- - )} - - {/* Global delivery history — shows all org deliveries when no specific webhook is selected */} - {!isLoading && (webhooks ?? []).length > 0 && ( -
-

- All Deliveries -

- -
- )} - - {/* Sheets + Dialogs */} - - setDeletingId(null)} /> -
- ); -} diff --git a/apps/web/src/app/(protected)/dashboard/page.tsx b/apps/web/src/app/(protected)/dashboard/page.tsx deleted file mode 100644 index 2f1bf99..0000000 --- a/apps/web/src/app/(protected)/dashboard/page.tsx +++ /dev/null @@ -1,879 +0,0 @@ -"use client"; - -import { useState, useMemo, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/useAuth"; -import { useOrgSummary, useLeaderboard } from "@/hooks/queries/useAnalytics"; -import { useMyDashboard } from "@/hooks/queries/useDashboard"; -import { useMyProfile } from "@/hooks/queries/useProfile"; -import { useOrgSessions } from "@/hooks/queries/useSessions"; -import { useOrgExpenses } from "@/hooks/queries/useExpenses"; -import { MetricCard } from "@/components/MetricCard"; -import { LeaderboardTable } from "@/components/charts/LeaderboardTable"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { EmptyState } from "@/components/EmptyState"; -import { StaggerList, StaggerItem, FadeUp } from "@/components/motion"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Skeleton } from "@/components/ui/skeleton"; -import { formatDistance, formatDuration, formatCurrency, formatTime } from "@/lib/utils"; -import { Activity, MapPin, Clock, Receipt, Users, Trophy, Zap, LogIn, LogOut, CheckCircle2, XCircle } from "lucide-react"; -import Link from "next/link"; -import type { OrgSummaryData, DashboardSummary, EmployeeProfileData } from "@/types"; -import { EmployeeIdentity } from "@/components/EmployeeIdentity"; -import { cn } from "@/lib/utils"; -import { todayRange } from "@/lib/dateRange"; - -// ─── Helper ─────────────────────────────────────────────────────────────────── - -function getFirstName(name: string | undefined | null, email: string | undefined | null) { - if (name) return name.split(" ")[0]; - if (email) return email.split("@")[0]; - return "there"; -} - -// ─── Admin Hero Card ────────────────────────────────────────────────────────── - -function AdminHeroCard({ - summary, - isLoading, -}: { - summary?: OrgSummaryData; - isLoading: boolean; -}) { - const { user } = useAuth(); - const { data: profile } = useMyProfile(); - const firstName = getFirstName(profile?.name, user?.email); - - return ( - - {/* Decorative circles */} -
-
- -
- {/* Left: greeting */} -
- - ADMIN - -

- Welcome back, {firstName} 👋 -

-

- Here's what's happening with your field team. -

-
- - {/* Right: key stats */} -
- {isLoading ? ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- ) : ( - <> -
-

- {(summary?.activeEmployeesCount ?? 0)} -

-

Active now

-
-
-
-

- {(summary?.totalSessions ?? 0).toLocaleString()} -

-

Sessions today

-
-
-
-

- {summary ? formatDistance(summary.totalDistanceKm) : "—"} -

-

Distance today

-
- - )} -
-
- - ); -} - -// ─── Org metrics grid ───────────────────────────────────────────────────────── - -function OrgSummarySection({ summary, isLoading }: { summary?: OrgSummaryData; isLoading: boolean }) { - const cards = [ - { - title: "Sessions Today", - value: summary?.totalSessions.toLocaleString() ?? "—", - numericValue: summary?.totalSessions, - icon: , - }, - { - title: "Distance Today", - value: summary ? formatDistance(summary.totalDistanceKm) : "—", - icon: , - }, - { - title: "Duration Today", - value: summary ? formatDuration(summary.totalDurationSeconds) : "—", - icon: , - }, - { - title: "Active Now", - value: summary?.activeEmployeesCount.toLocaleString() ?? "—", - numericValue: summary?.activeEmployeesCount, - icon: , - highlighted: true, - }, - { - title: "Expenses Today", - value: summary ? formatCurrency(summary.approvedExpenseAmount) : "—", - icon: , - }, - ]; - - return ( - - {cards.map((card) => ( - - - - ))} - - ); -} - -// ─── Activity status card ───────────────────────────────────────────────────── - -function ActivityStatusCard({ summary }: { summary?: OrgSummaryData }) { - if (!summary) { - return ( - - -
- {[1, 2, 3].map((i) => ( - - ))} -
-
-
- ); - } - - return ( - - - - - Live Activity - - - - {/* Active count with pulse */} -
-
- - - - - Active employees -
- - {summary.activeEmployeesCount} - -
- - {/* Expense breakdown */} -
-
- Total expenses - {summary.totalExpenses.toLocaleString()} -
-
- Approved - - {formatCurrency(summary.approvedExpenseAmount)} - -
-
- Rejected - - {formatCurrency(summary.rejectedExpenseAmount)} - -
-
-
-
- ); -} - -// ─── Admin leaderboard section ──────────────────────────────────────────────── - -type LeaderboardMetric = "distance" | "sessions" | "duration" | "expenses"; - -function AdminLeaderboardSection({ from, to }: { from: string; to: string }) { - const [metric, setMetric] = useState("distance"); - const { data, isLoading, error } = useLeaderboard(metric, 10, from, to); - - return ( - - - - - Top Performers Today - - setMetric(v as LeaderboardMetric)}> - - Distance - Sessions - Duration - Expenses - - - - - {error && } - {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : ( - - )} -
- - View full leaderboard → - -
-
-
- ); -} - -// ─── Live Activity Feed ─────────────────────────────────────────────────────── - -// Avatar gradient helpers (feed-local, mirrors EmployeeIdentity palette) -const FEED_PALETTE = [ - "from-blue-500 to-indigo-600", - "from-violet-500 to-purple-600", - "from-emerald-500 to-teal-600", - "from-rose-500 to-pink-600", - "from-cyan-500 to-blue-600", - "from-fuchsia-500 to-violet-600", - "from-amber-500 to-orange-600", - "from-teal-500 to-cyan-600", -]; -function feedGradient(name: string): string { - const s = Array.from(name).reduce((a, c) => a + c.charCodeAt(0), 0); - return FEED_PALETTE[s % FEED_PALETTE.length]; -} -function feedInitials(name: string): string { - return name.trim().split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase(); -} - -type ActivityEventType = - | "SESSION_CHECKIN" - | "SESSION_CHECKOUT" - | "EXPENSE_SUBMITTED" - | "EXPENSE_APPROVED" - | "EXPENSE_REJECTED"; - -interface ActivityFeedEntry { - id: string; - eventType: ActivityEventType; - employeeId: string; - name: string; - action: string; - detail?: string; - time: string; - ts: number; - href: string; -} - -const EVENT_CONFIG: Record< - ActivityEventType, - { bg: string; fg: string; dot: string; Icon: React.ElementType } -> = { - SESSION_CHECKIN: { - bg: "bg-emerald-100 dark:bg-emerald-950/40", - fg: "text-emerald-600 dark:text-emerald-400", - dot: "bg-emerald-500", - Icon: LogIn, - }, - SESSION_CHECKOUT: { - bg: "bg-blue-100 dark:bg-blue-950/40", - fg: "text-blue-600 dark:text-blue-400", - dot: "bg-blue-500", - Icon: LogOut, - }, - EXPENSE_SUBMITTED: { - bg: "bg-orange-100 dark:bg-orange-950/40", - fg: "text-orange-600 dark:text-orange-400", - dot: "bg-orange-500", - Icon: Receipt, - }, - EXPENSE_APPROVED: { - bg: "bg-emerald-100 dark:bg-emerald-950/40", - fg: "text-emerald-600 dark:text-emerald-400", - dot: "bg-emerald-500", - Icon: CheckCircle2, - }, - EXPENSE_REJECTED: { - bg: "bg-rose-100 dark:bg-rose-950/40", - fg: "text-rose-600 dark:text-rose-400", - dot: "bg-rose-500", - Icon: XCircle, - }, -}; - -function ActivityFeedItem({ - entry, - index, -}: { - entry: ActivityFeedEntry; - index: number; -}) { - const cfg = EVENT_CONFIG[entry.eventType]; - const { Icon } = cfg; - - return ( - - - {/* Avatar + event-type badge */} -
-
- {feedInitials(entry.name)} -
-
- -
-
- - {/* Content */} -
-

- - {entry.name} - {" "} - {entry.action} - {entry.detail && ( - · {entry.detail} - )} -

-

{entry.time}

-
- - {/* Event type dot */} -
- - - ); -} - -function TodayActivityFeed() { - const queryClient = useQueryClient(); - const sessions = useOrgSessions(1, 50); - const expenses = useOrgExpenses(1, 50); - - // Auto-refresh every 30 s so the feed stays live without a page reload - useEffect(() => { - const id = setInterval(() => { - void queryClient.invalidateQueries({ queryKey: ["orgSessions"] }); - void queryClient.invalidateQueries({ queryKey: ["orgExpenses"] }); - }, 30_000); - return () => clearInterval(id); - }, [queryClient]); - - const today = useMemo(() => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d; - }, []); - - const feed = useMemo(() => { - const entries: ActivityFeedEntry[] = []; - - for (const s of sessions.data?.data ?? []) { - const name = s.employee_name ?? "Unknown"; - - // Session check-in - const checkinTs = new Date(s.checkin_at); - if (checkinTs >= today) { - entries.push({ - id: `checkin-${s.id}`, - eventType: "SESSION_CHECKIN", - employeeId: s.employee_id, - name, - action: "checked in", - time: formatTime(s.checkin_at), - ts: checkinTs.getTime(), - href: "/admin/sessions", - }); - } - - // Session check-out - if (s.checkout_at) { - const checkoutTs = new Date(s.checkout_at); - if (checkoutTs >= today) { - entries.push({ - id: `checkout-${s.id}`, - eventType: "SESSION_CHECKOUT", - employeeId: s.employee_id, - name, - action: "completed session", - detail: - s.total_duration_seconds != null - ? formatDuration(s.total_duration_seconds) - : undefined, - time: formatTime(s.checkout_at), - ts: checkoutTs.getTime(), - href: "/admin/sessions", - }); - } - } - } - - for (const e of expenses.data?.data ?? []) { - const name = e.employee_name ?? "Unknown"; - - // Expense submitted - const submittedTs = new Date(e.submitted_at); - if (submittedTs >= today) { - entries.push({ - id: `expense-sub-${e.id}`, - eventType: "EXPENSE_SUBMITTED", - employeeId: e.employee_id, - name, - action: "submitted expense", - detail: formatCurrency(e.amount), - time: formatTime(e.submitted_at), - ts: submittedTs.getTime(), - href: "/admin/expenses", - }); - } - - // Expense reviewed (approved / rejected) - if (e.reviewed_at && (e.status === "APPROVED" || e.status === "REJECTED")) { - const reviewedTs = new Date(e.reviewed_at); - if (reviewedTs >= today) { - entries.push({ - id: `expense-review-${e.id}`, - eventType: - e.status === "APPROVED" ? "EXPENSE_APPROVED" : "EXPENSE_REJECTED", - employeeId: e.employee_id, - name, - action: - e.status === "APPROVED" ? "expense approved" : "expense rejected", - detail: formatCurrency(e.amount), - time: formatTime(e.reviewed_at), - ts: reviewedTs.getTime(), - href: "/admin/expenses", - }); - } - } - } - - return entries.sort((a, b) => b.ts - a.ts).slice(0, 20); - }, [sessions.data, expenses.data, today]); - - const isLoading = sessions.isLoading && expenses.isLoading; - - return ( - - -
- - - Today's Activity - -
- - - - - Live -
-
-
- - - {isLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} -
- ) : feed.length === 0 ? ( -
- -

- No activity yet today -

-

- Field events appear here as employees check in. -

-
- ) : ( - -
- {feed.map((entry, idx) => ( - - ))} -
-
- )} - - - {!isLoading && feed.length > 0 && ( -
- - {feed.length} event{feed.length !== 1 ? "s" : ""} today - - - All sessions → - -
- )} - - ); -} - -// ─── Team Activity Widget ───────────────────────────────────────────────────── - -function TeamActivityWidget({ from, to }: { from: string; to: string }) { - const { data, isLoading } = useLeaderboard("sessions", 7, from, to); - - return ( - - - - - Top Performers - - - - {isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
- ) : ( -
- {(data ?? []).map((entry, idx) => ( -
- - - {entry.sessions}s - -
- ))} -
- )} - - - ); -} - -// ─── Admin dashboard ────────────────────────────────────────────────────────── - -function AdminDashboard() { - const { from, to } = useMemo(() => todayRange(), []); - const summary = useOrgSummary(from, to); - - return ( -
- {summary.error && } - - {/* Hero banner */} - - - {/* Today's metrics row */} - - - {/* Today's activity feed + Live status */} - -
-
- -
-
- - -
-
-
- - {/* Today's leaderboard */} - - - -
- ); -} - -// ─── Employee Hero Card ─────────────────────────────────────────────────────── - -function EmployeeHeroCard({ - profile, - dashboard, - rank, - isLoading, -}: { - profile?: EmployeeProfileData; - dashboard?: DashboardSummary; - rank?: number; - isLoading: boolean; -}) { - const initials = profile?.name - ? profile.name - .split(" ") - .slice(0, 2) - .map((n) => n[0]) - .join("") - .toUpperCase() - : "?"; - - const statusLabel = - profile?.activityStatus === "ACTIVE" - ? "Active" - : profile?.activityStatus === "RECENT" - ? "Recently Active" - : "Inactive"; - - return ( - -
- -
-
- {/* Avatar */} -
- {isLoading ? "…" : initials} -
-
- - EMPLOYEE - -

- {isLoading ? ( - - ) : ( - profile?.name ?? "—" - )} -

- {/* Inline status indicator */} -
- - {profile?.activityStatus === "ACTIVE" && ( - - )} - - - {statusLabel} -
-
-
- - {/* Rank badge */} - {rank != null && ( -
- -
-

Distance Rank

-

#{rank}

-
-
- )} -
- - ); -} - -// ─── Employee dashboard ──────────────────────────────────────────────────────── - -function EmployeeDashboard() { - const { data: dashboard, isLoading: dashLoading, error: dashError } = useMyDashboard(); - const { data: profile, isLoading: profileLoading } = useMyProfile(); - const { data: leaderboard, isLoading: lbLoading } = useLeaderboard("distance", 10); - - const myRank = profile - ? leaderboard?.find((e) => e.employeeId === profile.id)?.rank - : undefined; - - const isLoading = dashLoading || profileLoading; - - if (dashError) return ; - - const stats = dashboard - ? [ - { - title: "Sessions This Week", - value: dashboard.sessionsThisWeek.toLocaleString(), - numericValue: dashboard.sessionsThisWeek, - icon: , - }, - { - title: "Distance This Week", - value: formatDistance(dashboard.distanceThisWeek), - icon: , - }, - { - title: "Hours Worked", - value: `${dashboard.hoursThisWeek.toFixed(1)} hrs`, - icon: , - }, - { - title: "Expenses Submitted", - value: dashboard.expensesSubmitted.toLocaleString(), - numericValue: dashboard.expensesSubmitted, - icon: , - }, - { - title: "Expenses Approved", - value: dashboard.expensesApproved.toLocaleString(), - numericValue: dashboard.expensesApproved, - icon: , - highlighted: true, - }, - ] - : []; - - return ( -
- {/* Hero card */} - - - {/* Weekly stats */} - - {stats.map((s) => ( - - - - ))} - - - {/* Leaderboard preview */} - - - - - - Distance Leaderboard - - - Full leaderboard → - - - - {lbLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : ( - - )} -
-
-
-
- ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function DashboardPage() { - const { permissions } = useAuth(); - return permissions.viewAnalytics ? : ; -} - diff --git a/apps/web/src/app/(protected)/error.tsx b/apps/web/src/app/(protected)/error.tsx deleted file mode 100644 index 946927a..0000000 --- a/apps/web/src/app/(protected)/error.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { AlertTriangle } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -interface ErrorPageProps { - error: Error & { digest?: string }; - reset: () => void; -} - -export default function ProtectedError({ error, reset }: ErrorPageProps) { - const router = useRouter(); - - useEffect(() => { - console.error("[FieldTrack] Error in protected area:", error); - }, [error]); - - return ( -
- - -
- -
- Something went wrong -
- -

- An unexpected error occurred while loading this page. -

- {error.digest && ( -

- Error ID: {error.digest} -

- )} -
- - -
-
-
-
- ); -} diff --git a/apps/web/src/app/(protected)/expenses/page.tsx b/apps/web/src/app/(protected)/expenses/page.tsx deleted file mode 100644 index e57daea..0000000 --- a/apps/web/src/app/(protected)/expenses/page.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useMyExpenses, useCreateExpense } from "@/hooks/queries/useExpenses"; -import { ExpensesTable } from "@/components/tables/ExpensesTable"; -import { ErrorBanner } from "@/components/ErrorBanner"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { useToast } from "@/components/ui/use-toast"; - -const PAGE_LIMIT = 20; - -export default function ExpensesPage() { - const [page, setPage] = useState(1); - const { data, isLoading, error } = useMyExpenses(page, PAGE_LIMIT); - const createExpense = useCreateExpense(); - const { toast } = useToast(); - - const [amount, setAmount] = useState(""); - const [description, setDescription] = useState(""); - const [receiptUrl, setReceiptUrl] = useState(""); - - const expenses = data?.data ?? []; - const total = data?.pagination.total ?? 0; - const hasMore = page * PAGE_LIMIT < total; - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const parsedAmount = parseFloat(amount); - if (isNaN(parsedAmount) || parsedAmount <= 0) { - toast({ variant: "destructive", title: "Invalid amount", description: "Enter a positive number." }); - return; - } - createExpense.mutate( - { amount: parsedAmount, description, receipt_url: receiptUrl || undefined }, - { - onSuccess: () => { - toast({ title: "Expense submitted", description: "Your expense claim has been submitted for review." }); - setAmount(""); - setDescription(""); - setReceiptUrl(""); - }, - onError: (err) => { - toast({ variant: "destructive", title: "Submission failed", description: err.message }); - }, - } - ); - } - - return ( -
-
-

My Expenses

-

Submit and track your expense claims.

-
- - - - Submit New Expense - - -
-
-
- - setAmount(e.target.value)} - required - /> -
-
- - setReceiptUrl(e.target.value)} - /> -
-
-
- -