For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Establish the foundation that unblocks all subsequent Sentinel_Cab v1 sprints — three repos under SCM, multi-tenant Postgres schema for all v1 entities, working device-bound auth, a Capacitor APK that boots into an idle screen on Galaxy XCover7, and bench-validated D-026 exit criteria.
Architecture: Three repos (poly-repo): sentinel-cab-api (Nest + TypeORM + Postgres on Neon, multi-tenant from day 1), sentinel-cab-app (React+Vite + Capacitor wrapped for Android), sentinel-cab-dashboard (React+Vite SPA behind Cloudflare Access). API exposes JSON over HTTPS. App and dashboard share a @sentinel-cab/shared workspace package for types and API client (introduced in Sprint 2; Sprint 1 just keeps repos parallel).
Tech Stack: Nest 10 + TypeORM + class-validator + Pino, Postgres 16 + PostGIS on Neon (Singapore region), Cloudflare R2 (Singapore region) for selfie storage, Cloudflare Access + Tunnel at cab.jofnd.ai, React 18 + Vite 5 + TypeScript 5, Capacitor 6 + Android 14 SDK target, Samsung Galaxy XCover7 (SM-G556B) as in-cab device, ACS ACR122U HF reader for bench, MIFARE DESFire EV3 cards for production / MIFARE Classic 1K for dev.
Audience: Skilled engineers with no prior context on this project. Each task has exact file paths, complete code, exact commands, and expected output.
Read decisions.md D-001 → D-026 and product-design.md before starting. Critical inputs for Sprint 1:
| Decision |
Effect on Sprint 1 |
| D-005 HID keyboard-wedge RFID |
Tablet code reads UID from a focused text input — no native RFID API plumbing |
| D-006 Capacitor + React+Vite |
Single React codebase, Capacitor wraps for Android |
| D-010 MIFARE DESFire EV3 |
Bench tests use ACR122U; expect 7-byte UIDs from real DESFire cards |
| D-012 Standalone Nest service |
New repo sentinel-cab-api, dedicated Neon DB |
| D-013 Multi-tenant from day one |
Every entity has tenant_id NOT NULL — locked from migration #1 |
| D-014 R2 Singapore for selfies |
R2 bucket created in Sprint 1 even though selfie code lands Sprint 3 |
| D-015 Driver-truck assignment |
truck_driver_assignments entity in Sprint 1 schema |
| D-017 CF Access + own session |
Auth scaffold splits into device-JWT (tablet) + session+CF (dashboard) |
| D-022/D-024 Continuation columns |
shifts table needs continuation_of_shift_id, continuation_reason, reassignment_window_until from migration #1 — no later schema rework |
| D-023/D-025 Geofence zone types |
geofences.zone_type enum has base_camp, loading_area, dumping_area, haul_route, restricted from day one |
| D-026 XCover7 not Tab Active5 |
Sprint 1 bench kit and exit criteria target the phone form factor |
By 2026-05-25 Sprint 1 demo, the following are demonstrable:
- Three repos exist under
josepheffendy/, each with CI green on main
- Neon project
sentinel-cab exists with production and dev branches
- Postgres schema has all v1 tables created via migrations, including PostGIS for
geofences.polygon
- R2 bucket
sentinel-cab-selfies exists with lifecycle policy
cab.jofnd.ai subdomain routes via Cloudflare Tunnel + Cloudflare Access to the API stub
- Device-JWT auth issues a token to a registered tablet device and validates it on a
/api/v1/health endpoint
- Session auth lets a seeded admin user log into a dashboard "hello" page through CF Access
- Capacitor APK built from
sentinel-cab-app, installed on the bench XCover7, boots into an idle screen showing "Tap your card to start" + truck plate placeholder + LTE/WiFi indicator
- RFID tap → UID-in-input proven: ACR122U reads a DESFire EV3 card; XCover7 receives the 7-byte UID into the focused input field via HID keyboard-wedge
- D-026 exit criteria pass: glove-tap reliability ≥95%, cradle vibration test passed, thermal test in closed car passed (no throttling at 4h / 40°C)
┌──────────────────────────────────┐
│ TASK 1: Bench kit order + arrive │
│ TASK 2: Repos created │
│ TASK 3: Neon project + branches │
│ TASK 4: R2 bucket created │
│ TASK 5: CF Tunnel + Access │
└────┬─────────────────────────────┘
│ (parallel where possible)
┌──────────┴───────────┐
│ │
┌────────▼─────────┐ ┌───────▼────────┐
│ BACKEND TRACK │ │ MOBILE TRACK │
│ 6: Nest scaffold │ │ 20: Vite init │
│ 7: Multi-tenant │ │ 21: Capacitor │
│ 8–17: Entities │ │ 22: Idle screen│
│ 18: Audit log │ │ 23: HID input │
│ 19: Auth scaffold│ │ 24: Knox lock │
└────────┬──────────┘ └───────┬─────────┘
│ │
└───────┬───────────────┘
│
┌───────────▼──────────────┐
│ HARDWARE VALIDATION │
│ 25: Glove-tap test │
│ 26: Cradle test │
│ 27: Thermal test │
└───────────┬──────────────┘
│
┌───────────▼──────────────┐
│ TASK 28: Sprint 1 demo │
└──────────────────────────┘
Backend track and Mobile track can proceed in parallel from Task 6/20 onward. Hardware validation depends on Mobile track Task 22 (idle screen).
Files:
- Reference: bench-procurement.md (existing)
- [ ] Step 1: Place Tokopedia/Shopee orders today
Per bench-procurement.md:
- 1× Samsung Galaxy XCover7 (search: Samsung Galaxy XCover7, model SM-G556B, ~Rp 5–6.5M, garansi resmi SEIN/TAM)
- 1× ACS ACR122U (search: ACR122U, ~Rp 700k–1M, genuine ACS)
- 1 pack × 5 MIFARE DESFire EV3 cards (search: MIFARE DESFire EV3 card, ~Rp 125–250k)
- 1 pack × 10 MIFARE Classic 1K cards (search: Kartu RFID MIFARE Classic 1K 10pcs, ~Rp 80–150k)
- 1× Ugreen USB-C OTG with PD passthrough (search: Ugreen USB-C OTG adapter PD passthrough, ~Rp 100–250k)
- 1× Aluminum desktop phone stand (~Rp 200–700k)
- Optional: 1× RAM Mounts X-Grip Quick Release for cradle bench rehearsal (~Rp 700k–1M, can also wait until Task 26)
Total: ~Rp 6.2–8.9 M
- [ ] Step 2: On delivery, run unboxing checklist
Per bench-procurement.md "Receiving / unboxing checklist" section:
- [ ] XCover7: BNIB seal intact, TAM warranty card, powers on, runs Android 14 update, NFC works, USB-C OTG works, XCover side key remappable in Android settings, front camera autofocuses in low light
- [ ] ACR122U: LED on insert, beep on card tap, recognized by Android via OTG
- [ ] DESFire EV3 cards: all 5 read with ACR122U, UIDs are 7 bytes (confirms genuine DESFire), unique UIDs per card
- [ ] MIFARE Classic 1K cards: all 10 read with ACR122U, unique UIDs
- [ ] OTG adapter: PD passthrough confirmed (XCover7 charging while ACR122U plugged into the OTG port)
If any item fails: return within Tokopedia 7-day window, reorder. Do not proceed to Tasks 22+ without working hardware.
- [ ] Step 3: Photograph the bench setup for the team handbook
Take 3–5 photos: full bench, OTG adapter detail, ACR122U + cards, XCover7 with NFC tag tap. Save to Argus_IIOP/Sentinel_Cab/bench-setup-photos/.
- [ ] Step 4: Commit photos
cd ~/Documents/01_Local_Work/CoWork
git add Argus_IIOP/Sentinel_Cab/bench-setup-photos/
git commit -m "docs(sentinel-cab): bench kit setup photos"
Files:
- Create: josepheffendy/sentinel-cab-api
- Create: josepheffendy/sentinel-cab-app
- Create: josepheffendy/sentinel-cab-dashboard
- [ ] Step 1: Create all three private repos via gh CLI
gh repo create josepheffendy/sentinel-cab-api --private --description "Sentinel_Cab API — Nest + Postgres on Neon" --add-readme
gh repo create josepheffendy/sentinel-cab-app --private --description "Sentinel_Cab in-cab app — React+Vite + Capacitor + Android" --add-readme
gh repo create josepheffendy/sentinel-cab-dashboard --private --description "Sentinel_Cab supervisor dashboard — React+Vite" --add-readme
Expected: each command prints https://github.com/josepheffendy/sentinel-cab-* URL.
- [ ] Step 2: Clone all three locally
mkdir -p ~/Code/sentinel-cab && cd ~/Code/sentinel-cab
gh repo clone josepheffendy/sentinel-cab-api
gh repo clone josepheffendy/sentinel-cab-app
gh repo clone josepheffendy/sentinel-cab-dashboard
ls
Expected: three directories listed.
- [ ] Step 3: Add .gitignore + LICENSE + initial README to each
Use the standard SwiftRise pattern. For each repo:
cd ~/Code/sentinel-cab/sentinel-cab-api
curl -sL https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore > .gitignore
echo "node_modules/" >> .gitignore
echo "dist/" >> .gitignore
echo ".env" >> .gitignore
echo ".env.local" >> .gitignore
Repeat for sentinel-cab-app and sentinel-cab-dashboard.
- [ ] Step 4: Commit and push initial state
For each repo:
git add -A
git commit -m "chore: initial repo setup with .gitignore"
git push origin main
Files:
- Reference: external (Neon dashboard at console.neon.tech)
- [ ] Step 1: Create the Neon project
In the Neon console:
1. Create new project named sentinel-cab
2. Region: Singapore (ap-southeast-1)
3. Postgres version: 16
4. Default branch: production
Note the connection string (looks like postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require).
- [ ] Step 2: Create a
dev branch from production
In Neon console: Branches → New branch → name dev, parent production. Note the dev connection string.
- [ ] Step 3: Enable PostGIS extension on both branches
Connect to each branch via psql:
psql "postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require" -c "CREATE EXTENSION IF NOT EXISTS postgis;"
Repeat for the dev branch connection string.
Expected: CREATE EXTENSION printed.
- [ ] Step 4: Verify PostGIS works
psql "postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require" -c "SELECT PostGIS_Version();"
Expected: prints PostGIS version (e.g. 3.4 USE_GEOS=1 USE_PROJ=1 USE_STATS=1).
- [ ] Step 5: Save connection strings to 1Password
Create two 1Password entries: Sentinel_Cab Neon production and Sentinel_Cab Neon dev. Do not commit connection strings to any repo.
Files:
- Reference: external (Cloudflare dashboard)
- [ ] Step 1: Create R2 bucket via wrangler
wrangler r2 bucket create sentinel-cab-selfies --location apac
Expected: Created bucket sentinel-cab-selfies.
- [ ] Step 2: Set bucket lifecycle policy (12-month retention per D-014)
Create ~/Code/sentinel-cab/r2-lifecycle.json:
{
"rules": [
{
"id": "auto-purge-after-12-months",
"enabled": true,
"conditions": {
"prefix": ""
},
"deleteObjectsTransition": {
"condition": { "type": "Age", "maxAge": 31536000 }
}
}
]
}
Apply:
wrangler r2 bucket lifecycle set sentinel-cab-selfies --file ~/Code/sentinel-cab/r2-lifecycle.json
Expected: success confirmation. (If the wrangler subcommand syntax differs in your version, set the lifecycle via the Cloudflare dashboard: R2 → sentinel-cab-selfies → Settings → Object lifecycle rules.)
- [ ] Step 3: Create R2 API token scoped to this bucket
In Cloudflare dashboard: R2 → Manage R2 API Tokens → Create API token. Permissions: Object Read & Write. Scope: sentinel-cab-selfies. Save Access Key ID + Secret Access Key + Endpoint to 1Password as Sentinel_Cab R2 production credentials.
Files:
- Reference: external (Cloudflare Zero Trust dashboard)
- [ ] Step 1: Create CF Access application
In Cloudflare Zero Trust dashboard: Access → Applications → Add an application:
- Type: Self-hosted
- Application name: Sentinel_Cab
- Domain: cab.jofnd.ai (or pick another sub if cab is taken)
- Identity providers: Google + One-time PIN (so Adong supervisors can use email-OTP later)
Add policy:
- Name: Sentinel_Cab admins
- Action: Allow
- Include: emails ending jofnd.ai, plus josepheffendy@gmail.com
(More-permissive Adong-supervisor policies added later.)
- [ ] Step 2: Create CF Tunnel from agent-mini host (or wherever the API will run)
cloudflared tunnel create sentinel-cab
cloudflared tunnel route dns sentinel-cab cab.jofnd.ai
Expected: tunnel UUID printed, DNS record created.
- [ ] Step 3: Configure tunnel to forward to local Nest port
Create ~/.cloudflared/sentinel-cab.yml:
tunnel: <TUNNEL-UUID>
credentials-file: /Users/jofnd.ai/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: cab.jofnd.ai
service: http://localhost:3010
- service: http_status:404
Note: port 3010 is reserved for Sentinel_Cab API (matches existing app port allocation pattern).
- [ ] Step 4: Start the tunnel as a LaunchAgent
Create ~/Library/LaunchAgents/com.jofnd.sentinel-cab-tunnel.plist mirroring the existing asset / procure tunnel LaunchAgents (per project_assetmgmt_deploy and project_procure_system memory references):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jofnd.sentinel-cab-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>tunnel</string>
<string>--config</string>
<string>/Users/jofnd.ai/.cloudflared/sentinel-cab.yml</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/sentinel-cab-tunnel.log</string>
<key>StandardErrorPath</key>
<string>/tmp/sentinel-cab-tunnel.err.log</string>
</dict>
</plist>
Load it:
launchctl load ~/Library/LaunchAgents/com.jofnd.sentinel-cab-tunnel.plist
launchctl start com.jofnd.sentinel-cab-tunnel
- [ ] Step 5: Verify tunnel is up (will return 502 until API runs — that's expected)
curl -I https://cab.jofnd.ai/
Expected: HTTP/2 502 (Bad Gateway — no upstream listening yet) OR HTTP/2 401 (CF Access challenge) depending on policy ordering. Both confirm tunnel routing works.
Files:
- Modify: ~/Code/sentinel-cab/sentinel-cab-api/ (entire repo)
- [ ] Step 1: Copy SwiftRise Nest boilerplate
gh repo clone josepheffendy/swiftrise-boilerplate-api-nest /tmp/swiftrise-nest-tmp
rsync -av --exclude='.git' --exclude='node_modules' --exclude='dist' /tmp/swiftrise-nest-tmp/ ~/Code/sentinel-cab/sentinel-cab-api/
cd ~/Code/sentinel-cab/sentinel-cab-api
rm -rf /tmp/swiftrise-nest-tmp
- [ ] Step 2: Apply Postgres-specific bug-fixes per
feedback_swiftrise_boilerplate_postgres_bugs
Open ~/Code/sentinel-cab/sentinel-cab-api/src/database/data-source.ts and verify it uses relative imports, not from 'src/...'. If the boilerplate has the alias-import bug, change it to relative imports.
Open ~/Code/sentinel-cab/sentinel-cab-api/tsconfig.json and ensure strictPropertyInitialization: false is set.
Search for unsigned: true and longtext in src/:
cd ~/Code/sentinel-cab/sentinel-cab-api
grep -rn "unsigned: true\|longtext" src/
Replace any occurrences (they break Postgres). Per the boilerplate-bugs feedback memory.
- [ ] Step 3: Update package.json identity
Edit package.json:
{
"name": "sentinel-cab-api",
"version": "0.1.0",
"description": "Sentinel_Cab API — Nest + Postgres on Neon"
}
- [ ] Step 4: Install Postgres deps (boilerplate ships MySQL defaults)
cd ~/Code/sentinel-cab/sentinel-cab-api
npm uninstall mysql2 || true
npm install pg @types/pg
- [ ] Step 5: Configure .env.example with Postgres + Neon
Create .env.example:
NODE_ENV=development
PORT=3010
# Database (Neon Postgres — use the "dev" branch connection string from Task 3)
DB_TYPE=postgres
DATABASE_URL=postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require
# JWT
JWT_SECRET=replace-me-in-env
JWT_EXPIRES_IN=15m
# R2 (selfie storage)
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=sentinel-cab-selfies
R2_ENDPOINT=
# Tenant defaults
DEFAULT_TENANT_CODE=adong
Also create .env locally (NOT committed) with your real Neon dev connection string.
- [ ] Step 6: Add a
/health endpoint that proves DB connectivity
Open src/app.controller.ts. Replace its body with:
import { Controller, Get } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Controller()
export class AppController {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
@Get('health')
async health() {
const result = await this.dataSource.query('SELECT 1 as ok');
return {
status: 'ok',
service: 'sentinel-cab-api',
version: process.env.npm_package_version || '0.1.0',
db: result[0]?.ok === 1 ? 'connected' : 'unknown',
timestamp: new Date().toISOString(),
};
}
}
- [ ] Step 7: Run the app locally and hit /health
Terminal 1:
cd ~/Code/sentinel-cab/sentinel-cab-api
npm install
npm run start:dev
Expected: starts on port 3010 with no errors.
Terminal 2:
curl http://localhost:3010/health
Expected:
{"status":"ok","service":"sentinel-cab-api","version":"0.1.0","db":"connected","timestamp":"2026-05-..."}
- [ ] Step 8: Hit /health through the CF Tunnel (CF Access will challenge)
curl -I https://cab.jofnd.ai/health
Expected: HTTP/2 302 with location: https://...cloudflareaccess.com/... (CF Access OAuth challenge). Confirms tunnel + Access wiring.
- [ ] Step 9: Commit + push
git add -A
git commit -m "feat: initial Nest scaffold from SwiftRise boilerplate with Postgres patches and /health endpoint"
git push origin main
Files:
- Create: ~/Code/sentinel-cab/sentinel-cab-api/src/common/entities/tenant-scoped.entity.ts
- Create: ~/Code/sentinel-cab/sentinel-cab-api/src/common/middleware/tenant-context.middleware.ts
- Create: ~/Code/sentinel-cab/sentinel-cab-api/src/common/decorators/current-tenant.decorator.ts
- Test: ~/Code/sentinel-cab/sentinel-cab-api/test/tenant-context.middleware.spec.ts
- [ ] Step 1: Write the failing middleware test
Create test/tenant-context.middleware.spec.ts:
import { TenantContextMiddleware } from '../src/common/middleware/tenant-context.middleware';
import { Request, Response, NextFunction } from 'express';
describe('TenantContextMiddleware', () => {
let middleware: TenantContextMiddleware;
let next: NextFunction;
beforeEach(() => {
middleware = new TenantContextMiddleware();
next = jest.fn();
});
it('attaches tenantId to request when X-Tenant-Id header present', () => {
const req = { headers: { 'x-tenant-id': '00000000-0000-0000-0000-000000000001' } } as unknown as Request;
const res = {} as Response;
middleware.use(req, res, next);
expect((req as any).tenantId).toBe('00000000-0000-0000-0000-000000000001');
expect(next).toHaveBeenCalled();
});
it('rejects requests with missing X-Tenant-Id header', () => {
const req = { headers: {}, path: '/api/v1/protected' } as unknown as Request;
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as unknown as Response;
middleware.use(req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});
it('skips tenant check on health endpoint', () => {
const req = { headers: {}, path: '/health' } as unknown as Request;
const res = {} as Response;
middleware.use(req, res, next);
expect(next).toHaveBeenCalled();
});
});
- [ ] Step 2: Run the test, watch it fail
cd ~/Code/sentinel-cab/sentinel-cab-api
npm test -- tenant-context
Expected: FAIL with "Cannot find module '../src/common/middleware/tenant-context.middleware'".
- [ ] Step 3: Create the middleware
Create src/common/middleware/tenant-context.middleware.ts:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
const PUBLIC_PATHS = ['/health', '/'];
@Injectable()
export class TenantContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
if (PUBLIC_PATHS.includes(req.path)) {
return next();
}
const tenantId = req.headers['x-tenant-id'] as string | undefined;
if (!tenantId) {
res.status(400).json({
code: 'TENANT_REQUIRED',
message: 'X-Tenant-Id header is required for this endpoint',
});
return;
}
(req as any).tenantId = tenantId;
next();
}
}
- [ ] Step 4: Wire the middleware in app.module
Open src/app.module.ts. Add:
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { TenantContextMiddleware } from './common/middleware/tenant-context.middleware';
@Module({ /* ...existing imports... */ })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantContextMiddleware).forRoutes('*');
}
}
- [ ] Step 5: Run the test, watch it pass
npm test -- tenant-context
Expected: 3 tests PASS.
- [ ] Step 6: Create the @CurrentTenant decorator
Create src/common/decorators/current-tenant.decorator.ts:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentTenant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.tenantId;
},
);
- [ ] Step 7: Create the TenantScoped base entity
Create src/common/entities/tenant-scoped.entity.ts:
import { Column, CreateDateColumn, DeleteDateColumn, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
export abstract class TenantScopedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index()
@Column('uuid', { name: 'tenant_id', nullable: false })
tenantId!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string | null;
}
git add -A
git commit -m "feat: multi-tenant middleware + @CurrentTenant decorator + TenantScopedEntity base"
git push origin main
Files:
- Create: ~/Code/sentinel-cab/sentinel-cab-api/.github/workflows/ci.yml
- [ ] Step 1: Add Postgres + PostGIS test service to CI
Create .github/workflows/ci.yml:
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: sentinel_cab_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Run tests
env:
DB_TYPE: postgres
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/sentinel_cab_test
JWT_SECRET: ci-test-secret
run: npm test
- [ ] Step 2: Push and verify CI passes
git add .github/workflows/ci.yml
git commit -m "ci: GitHub Actions with Postgres+PostGIS test service"
git push origin main
gh run watch
Expected: CI green within ~3 min.
Files:
- Modify: ~/Code/sentinel-cab/sentinel-cab-api/package.json
- Create: ~/Code/sentinel-cab/sentinel-cab-api/src/database/migrations/.gitkeep
- [ ] Step 1: Add migration scripts to package.json
Edit package.json, add to "scripts":
{
"scripts": {
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts",
"typeorm": "typeorm-ts-node-commonjs"
}
}
- [ ] Step 2: Confirm migration directory exists
mkdir -p src/database/migrations
touch src/database/migrations/.gitkeep
git add -A
git commit -m "chore: migration scripts and directory"
git push origin main
Pattern for entity tasks (10–17): each follows the same shape — write entity → write migration → write repository unit test → run + commit. After Task 10 walks the full TDD cycle, Tasks 11–17 are presented as compressed templates with the entity file, migration, and test code provided directly. Engineers proficient in TypeORM/Nest can apply the pattern without restating each step.
Files:
- Create: ~/Code/sentinel-cab/sentinel-cab-api/src/modules/tenants/tenant.entity.ts
- Create: ~/Code/sentinel-cab/sentinel-cab-api/src/modules/tenants/tenants.module.ts
- Test: ~/Code/sentinel-cab/sentinel-cab-api/test/modules/tenants/tenant.entity.spec.ts
- Migration: ~/Code/sentinel-cab/sentinel-cab-api/src/database/migrations/<TIMESTAMP>-CreateTenants.ts (auto-generated)
- [ ] Step 1: Write the failing entity test
Create test/modules/tenants/tenant.entity.spec.ts:
import { Tenant } from '../../../src/modules/tenants/tenant.entity';
describe('Tenant entity', () => {
it('exposes the expected columns', () => {
const t = new Tenant();
t.name = 'Adong';
t.code = 'adong';
t.siteCountry = 'ID';
expect(t.name).toBe('Adong');
expect(t.code).toBe('adong');
expect(t.siteCountry).toBe('ID');
});
});
- [ ] Step 2: Run, watch fail
npm test -- tenant.entity
Expected: FAIL with module-not-found.
- [ ] Step 3: Create the entity
Create src/modules/tenants/tenant.entity.ts:
import { Column, CreateDateColumn, DeleteDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity({ name: 'tenants' })
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index({ unique: true })
@Column({ length: 64 })
code!: string;
@Column({ length: 256 })
name!: string;
@Column({ name: 'site_country', length: 2, nullable: true })
siteCountry?: string;
@Column({ name: 'pdp_consent_template_version', length: 32, nullable: true })
pdpConsentTemplateVersion?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt?: Date | null;
}
- [ ] Step 4: Create the module shell
Create src/modules/tenants/tenants.module.ts:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from './tenant.entity';
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
exports: [TypeOrmModule],
})
export class TenantsModule {}
Register TenantsModule in app.module.ts imports array.
- [ ] Step 5: Generate migration
npm run migration:generate -- src/database/migrations/CreateTenants
Expected: migration file created in src/database/migrations/<TIMESTAMP>-CreateTenants.ts with CREATE TABLE tenants (...).
Inspect the generated SQL to confirm: UUID PK, unique index on code, soft-delete column, timestamps.
- [ ] Step 6: Run the migration against dev DB
DATABASE_URL=<dev-connection-string> npm run migration:run
Expected: Migration <name> has been executed successfully.
- [ ] Step 7: Verify table exists in Neon
psql "<dev-connection-string>" -c "\d tenants"
Expected: prints columns matching the entity definition.
- [ ] Step 8: Run tests, watch them pass
npm test -- tenant.entity
Expected: PASS.
git add -A
git commit -m "feat(tenants): tenant entity + migration"
git push origin main
Files:
- Create: src/modules/users/user.entity.ts
- Create: src/modules/users/users.module.ts
- Test: test/modules/users/user.entity.spec.ts
- Migration: src/database/migrations/<TIMESTAMP>-CreateUsers.ts
Apply the Task 10 TDD cycle (test → fail → entity → migration → run → pass → commit) with this entity:
// src/modules/users/user.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
export type UserRole = 'admin' | 'supervisor' | 'viewer';
@Entity({ name: 'users' })
@Index(['tenantId', 'email'], { unique: true, where: 'deleted_at IS NULL' })
export class User extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ length: 256 })
email!: string;
@Column({ length: 256 })
name!: string;
@Column({ name: 'password_hash', length: 256 })
passwordHash!: string;
@Column({ type: 'enum', enum: ['admin', 'supervisor', 'viewer'], default: 'viewer' })
role!: UserRole;
@Column({ name: 'mfa_enabled', default: false })
mfaEnabled!: boolean;
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
lastLoginAt?: Date | null;
}
Test:
// test/modules/users/user.entity.spec.ts
import { User } from '../../../src/modules/users/user.entity';
describe('User entity', () => {
it('defaults role to viewer', () => {
const u = new User();
u.email = 'test@example.com';
u.name = 'Test';
u.passwordHash = 'hash';
expect(u.role || 'viewer').toBe('viewer');
});
});
Commit message: feat(users): user entity + migration with tenant-scoped unique email
Apply the pattern with:
// src/modules/drivers/driver.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
export type DriverStatus = 'active' | 'inactive' | 'terminated';
@Entity({ name: 'drivers' })
@Index(['tenantId', 'employeeId'], { unique: true, where: 'deleted_at IS NULL' })
export class Driver extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ name: 'employee_id', length: 64 })
employeeId!: string;
@Column({ length: 256 })
name!: string;
@Column({ name: 'name_phonetic', length: 256, nullable: true })
namePhonetic?: string | null;
@Column({ type: 'date', nullable: true })
dob?: string | null;
@Column({ name: 'license_class', length: 32, nullable: true })
licenseClass?: string | null;
@Column({ name: 'license_number', length: 64, nullable: true })
licenseNumber?: string | null;
@Column({ name: 'license_expires_at', type: 'date', nullable: true })
licenseExpiresAt?: string | null;
@Column({ name: 'hire_date', type: 'date', nullable: true })
hireDate?: string | null;
@Column({ name: 'photo_url', length: 1024, nullable: true })
photoUrl?: string | null;
@Column({ type: 'enum', enum: ['active', 'inactive', 'terminated'], default: 'active' })
status!: DriverStatus;
@Column({ name: 'consent_signed_at', type: 'timestamptz', nullable: true })
consentSignedAt?: Date | null;
@Column({ name: 'consent_version', length: 32, nullable: true })
consentVersion?: string | null;
@Column({ type: 'text', nullable: true })
notes?: string | null;
}
Commit: feat(drivers): driver entity + migration with tenant-scoped unique employee_id
// src/modules/rfid-cards/rfid-card.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
import { Driver } from '../drivers/driver.entity';
export type RfidCardStatus = 'active' | 'lost' | 'revoked' | 'expired';
@Entity({ name: 'rfid_cards' })
@Index(['tenantId', 'uid'], { unique: true })
export class RfidCard extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ length: 32 })
uid!: string; // 7-byte DESFire UID = 14 hex chars; padding allows for future card formats
@Column({ name: 'chip_type', length: 32, default: 'desfire-ev3' })
chipType!: string;
@ManyToOne(() => Driver, { nullable: true }) @JoinColumn({ name: 'issued_to_driver_id' })
issuedToDriver?: Driver | null;
@Column({ name: 'issued_to_driver_id', type: 'uuid', nullable: true })
issuedToDriverId?: string | null;
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
issuedAt?: Date | null;
@Column({ name: 'issued_by', type: 'uuid', nullable: true })
issuedBy?: string | null;
@Column({ type: 'enum', enum: ['active', 'lost', 'revoked', 'expired'], default: 'active' })
status!: RfidCardStatus;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt?: Date | null;
@Column({ name: 'revoked_by', type: 'uuid', nullable: true })
revokedBy?: string | null;
@Column({ name: 'revoke_reason', length: 256, nullable: true })
revokeReason?: string | null;
@Column({ type: 'text', nullable: true })
notes?: string | null;
}
Commit: feat(rfid-cards): card entity + migration with tenant-scoped unique UID
// src/modules/trucks/truck.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
export type TruckStatus = 'active' | 'workshop' | 'retired';
@Entity({ name: 'trucks' })
@Index(['tenantId', 'assetCode'], { unique: true, where: 'deleted_at IS NULL' })
export class Truck extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ name: 'asset_code', length: 64 })
assetCode!: string;
@Column({ name: 'plate_number', length: 32, nullable: true })
plateNumber?: string | null;
@Column({ length: 64, nullable: true })
model?: string | null;
@Column({ name: 'model_class', length: 32, nullable: true })
modelClass?: string | null;
@Column({ type: 'int', nullable: true })
year?: number | null;
@Column({ length: 32, nullable: true })
vin?: string | null;
@Column({ type: 'enum', enum: ['active', 'workshop', 'retired'], default: 'active' })
status!: TruckStatus;
@Column({ name: 'primary_tablet_id', type: 'uuid', nullable: true })
primaryTabletId?: string | null;
@Column({ name: 'primary_reader_id', type: 'uuid', nullable: true })
primaryReaderId?: string | null;
}
Commit: feat(trucks): truck entity + migration with tenant-scoped unique asset_code
Two entities in one task — both small.
// src/modules/tablet-devices/tablet-device.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
export type TabletStatus = 'provisioned' | 'active' | 'retired' | 'lost';
@Entity({ name: 'tablet_devices' })
@Index(['tenantId', 'hardwareSerial'], { unique: true })
export class TabletDevice extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ name: 'hardware_serial', length: 64 })
hardwareSerial!: string;
@Column({ length: 64, default: 'Galaxy XCover7' })
model!: string;
@Column({ name: 'os_version', length: 32, nullable: true })
osVersion?: string | null;
@Column({ name: 'app_version', length: 32, nullable: true })
appVersion?: string | null;
@Column({ name: 'api_token_hash', length: 256, nullable: true })
apiTokenHash?: string | null;
@Column({ name: 'last_check_in_at', type: 'timestamptz', nullable: true })
lastCheckInAt?: Date | null;
@Column({ type: 'enum', enum: ['provisioned', 'active', 'retired', 'lost'], default: 'provisioned' })
status!: TabletStatus;
}
// src/modules/rfid-readers/rfid-reader.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
export type ReaderStatus = 'provisioned' | 'active' | 'retired' | 'broken';
@Entity({ name: 'rfid_readers' })
@Index(['tenantId', 'hardwareSerial'], { unique: true })
export class RfidReader extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ name: 'hardware_serial', length: 64 })
hardwareSerial!: string;
@Column({ length: 64, nullable: true })
model?: string | null;
@Column({ name: 'mounting_location', length: 128, nullable: true })
mountingLocation?: string | null;
@Column({ type: 'enum', enum: ['provisioned', 'active', 'retired', 'broken'], default: 'provisioned' })
status!: ReaderStatus;
}
Commit: feat(devices): tablet_devices + rfid_readers entities + migrations
// src/modules/truck-driver-assignments/truck-driver-assignment.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
import { Truck } from '../trucks/truck.entity';
import { Driver } from '../drivers/driver.entity';
export type AssignmentRole = 'primary' | 'backup';
@Entity({ name: 'truck_driver_assignments' })
@Index(['tenantId', 'truckId'], {
unique: true,
where: "role = 'primary' AND valid_to IS NULL AND deleted_at IS NULL",
})
@Index(['tenantId', 'truckId', 'driverId', 'role'])
export class TruckDriverAssignment extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@ManyToOne(() => Truck) @JoinColumn({ name: 'truck_id' })
truck!: Truck;
@Column({ name: 'truck_id', type: 'uuid' })
truckId!: string;
@ManyToOne(() => Driver) @JoinColumn({ name: 'driver_id' })
driver!: Driver;
@Column({ name: 'driver_id', type: 'uuid' })
driverId!: string;
@Column({ type: 'enum', enum: ['primary', 'backup'] })
role!: AssignmentRole;
@Column({ name: 'valid_from', type: 'timestamptz' })
validFrom!: Date;
@Column({ name: 'valid_to', type: 'timestamptz', nullable: true })
validTo?: Date | null;
@Column({ name: 'assigned_by_user_id', type: 'uuid', nullable: true })
assignedByUserId?: string | null;
@Column({ name: 'assigned_at', type: 'timestamptz', nullable: true })
assignedAt?: Date | null;
@Column({ name: 'revoked_by_user_id', type: 'uuid', nullable: true })
revokedByUserId?: string | null;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt?: Date | null;
@Column({ type: 'text', nullable: true })
notes?: string | null;
}
Test the partial unique constraint:
// test/modules/truck-driver-assignments/uniqueness.spec.ts
// (integration test against test DB — verifies that two open primary assignments for the same truck are rejected)
import { Test } from '@nestjs/testing';
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TruckDriverAssignment } from '../../../src/modules/truck-driver-assignments/truck-driver-assignment.entity';
describe('truck_driver_assignments uniqueness', () => {
let repo: Repository<TruckDriverAssignment>;
beforeAll(async () => {
// Test setup using process.env.DATABASE_URL (CI Postgres)
// ...wire repo from a test module that loads only the entities under test...
});
it('rejects two open primary assignments on the same truck', async () => {
const tenantId = '00000000-0000-0000-0000-000000000001';
const truckId = '00000000-0000-0000-0000-0000000000aa';
const driver1 = '00000000-0000-0000-0000-0000000000b1';
const driver2 = '00000000-0000-0000-0000-0000000000b2';
await repo.save({ tenantId, truckId, driverId: driver1, role: 'primary', validFrom: new Date(), validTo: null });
await expect(
repo.save({ tenantId, truckId, driverId: driver2, role: 'primary', validFrom: new Date(), validTo: null }),
).rejects.toThrow();
});
});
Commit: feat(assignments): truck_driver_assignments with partial unique on active primary per truck
// src/modules/geofences/geofence.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
export type ZoneType = 'base_camp' | 'loading_area' | 'dumping_area' | 'haul_route' | 'restricted';
@Entity({ name: 'geofences' })
@Index(['tenantId', 'zoneType', 'isActive'])
export class Geofence extends TenantScopedEntity {
@ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
tenant!: Tenant;
@Column({ length: 128 })
name!: string;
@Column({
name: 'zone_type',
type: 'enum',
enum: ['base_camp', 'loading_area', 'dumping_area', 'haul_route', 'restricted'],
})
zoneType!: ZoneType;
@Column({
type: 'geometry',
spatialFeatureType: 'Polygon',
srid: 4326,
})
polygon!: any; // GeoJSON Polygon
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@Column({ type: 'text', nullable: true })
notes?: string | null;
}
Test:
// test/modules/geofences/spatial.spec.ts
// Integration test — insert a polygon, query "is point inside?"
import { Repository } from 'typeorm';
import { Geofence } from '../../../src/modules/geofences/geofence.entity';
describe('geofences spatial queries', () => {
let repo: Repository<Geofence>;
beforeAll(async () => { /* wire repo */ });
it('returns geofence containing a given point', async () => {
const tenantId = '00000000-0000-0000-0000-000000000001';
// Polygon roughly bounding (lon 116.5, lat -1.5) at Adong
const polygon = {
type: 'Polygon',
coordinates: [[
[116.4, -1.6], [116.6, -1.6], [116.6, -1.4], [116.4, -1.4], [116.4, -1.6],
]],
};
await repo.save({ tenantId, name: 'test base camp', zoneType: 'base_camp', polygon, isActive: true });
const point = 'SRID=4326;POINT(116.5 -1.5)';
const found = await repo
.createQueryBuilder('g')
.where('g.tenant_id = :tenantId', { tenantId })
.andWhere('ST_Contains(g.polygon, ST_GeomFromEWKT(:p))', { p: point })
.getOne();
expect(found?.name).toBe('test base camp');
});
});
Commit: feat(geofences): geofence entity with PostGIS polygon + spatial containment query
// src/modules/audit-logs/audit-log.entity.ts
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export type ActorType = 'user' | 'tablet' | 'system';
@Entity({ name: 'audit_logs' })
@Index(['tenantId', 'occurredAt'])
@Index(['tenantId', 'entityType', 'entityId'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId!: string;
@Column({ name: 'actor_type', type: 'enum', enum: ['user', 'tablet', 'system'] })
actorType!: ActorType;
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
actorId?: string | null;
@Column({ length: 64 })
action!: string;
@Column({ name: 'entity_type', length: 64, nullable: true })
entityType?: string | null;
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
entityId?: string | null;
@Column({ name: 'before_state', type: 'jsonb', nullable: true })
beforeState?: any;
@Column({ name: 'after_state', type: 'jsonb', nullable: true })
afterState?: any;
@Column({ name: 'ip_address', length: 64, nullable: true })
ipAddress?: string | null;
@Column({ name: 'user_agent', length: 256, nullable: true })
userAgent?: string | null;
@Column({ length: 32, nullable: true })
source?: string | null;
@CreateDateColumn({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
}
(Note: audit_logs does NOT extend TenantScopedEntity because it has no soft-delete and no updated_at — audit log entries are immutable once written.)
Commit: feat(audit-logs): immutable audit_logs entity
Files:
- Create: src/modules/auth/auth.module.ts
- Create: src/modules/auth/auth.service.ts
- Create: src/modules/auth/auth.controller.ts
- Create: src/modules/auth/dto/login.dto.ts
- Create: src/modules/auth/strategies/session.strategy.ts
- Create: src/modules/auth/guards/session-auth.guard.ts
- Test: test/modules/auth/auth.service.spec.ts
npm install @nestjs/passport passport passport-local express-session bcrypt
npm install -D @types/passport-local @types/express-session @types/bcrypt
- [ ] Step 2: Write auth.service.spec.ts test for password verification
Create test/modules/auth/auth.service.spec.ts:
import { AuthService } from '../../../src/modules/auth/auth.service';
import * as bcrypt from 'bcrypt';
describe('AuthService', () => {
let usersRepo: any;
let svc: AuthService;
beforeEach(() => {
usersRepo = { findOne: jest.fn() };
svc = new AuthService(usersRepo as any);
});
it('returns user on correct password', async () => {
const hash = await bcrypt.hash('correct-password', 10);
usersRepo.findOne.mockResolvedValue({ id: 'u1', email: 'a@b.c', passwordHash: hash, tenantId: 't1', role: 'admin' });
const result = await svc.validate('a@b.c', 'correct-password');
expect(result?.id).toBe('u1');
});
it('returns null on wrong password', async () => {
const hash = await bcrypt.hash('correct-password', 10);
usersRepo.findOne.mockResolvedValue({ id: 'u1', email: 'a@b.c', passwordHash: hash, tenantId: 't1', role: 'admin' });
const result = await svc.validate('a@b.c', 'wrong-password');
expect(result).toBeNull();
});
it('returns null on unknown user', async () => {
usersRepo.findOne.mockResolvedValue(null);
const result = await svc.validate('nope@b.c', 'whatever');
expect(result).toBeNull();
});
});
- [ ] Step 3: Run, watch fail
npm test -- auth.service
Expected: FAIL.
- [ ] Step 4: Implement AuthService
Create src/modules/auth/auth.service.ts:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../users/user.entity';
export interface AuthenticatedUser {
id: string;
email: string;
tenantId: string;
role: string;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
) {}
async validate(email: string, password: string): Promise<AuthenticatedUser | null> {
const user = await this.users.findOne({ where: { email } });
if (!user) return null;
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return null;
return { id: user.id, email: user.email, tenantId: user.tenantId, role: user.role };
}
}
- [ ] Step 5: Run test, watch pass
npm test -- auth.service
Expected: 3 tests PASS.
- [ ] Step 6: Wire express-session + passport-local + login/logout endpoints
Create src/modules/auth/strategies/session.strategy.ts:
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor(private readonly auth: AuthService) {
super({ usernameField: 'email' });
}
async validate(email: string, password: string) {
const user = await this.auth.validate(email, password);
if (!user) throw new UnauthorizedException();
return user;
}
}
Create src/modules/auth/guards/session-auth.guard.ts:
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
async canActivate(context: ExecutionContext): Promise<boolean> {
const result = (await super.canActivate(context)) as boolean;
const req = context.switchToHttp().getRequest();
await new Promise<void>((resolve, reject) => {
req.logIn(req.user, (err: any) => (err ? reject(err) : resolve()));
});
return result;
}
}
@Injectable()
export class SessionGuard {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
return req.isAuthenticated && req.isAuthenticated();
}
}
Create src/modules/auth/auth.controller.ts:
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { LocalAuthGuard, SessionGuard } from './guards/session-auth.guard';
import { Request } from 'express';
@Controller('api/v1/auth')
export class AuthController {
@UseGuards(LocalAuthGuard)
@Post('login')
login(@Req() req: Request) {
return { user: req.user };
}
@UseGuards(SessionGuard)
@Get('me')
me(@Req() req: Request) {
return { user: req.user };
}
@Post('logout')
logout(@Req() req: Request) {
return new Promise((resolve) => req.logout(() => resolve({ ok: true })));
}
}
Wire express-session in src/main.ts:
import * as session from 'express-session';
import * as passport from 'passport';
// in bootstrap():
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-only-change-in-prod',
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' },
}));
app.use(passport.initialize());
app.use(passport.session());
Per feedback_swiftrise_dashboard_proxy memory: dev dashboard must call /api via Vite proxy (relative URL), NOT absolute VITE_API_URL — otherwise SameSite=Lax cookie drops. Document this in sentinel-cab-dashboard/README.md for the dashboard dev.
- [ ] Step 7: Seed an admin user for Adong tenant in dev DB
Create src/database/seeds/0001-seed-tenant-admin.ts:
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcrypt';
export async function seedTenantAdmin(ds: DataSource) {
const tenantId = '00000000-0000-0000-0000-000000000001';
await ds.query(
`INSERT INTO tenants (id, code, name, site_country) VALUES ($1, 'adong', 'Adong (Runa)', 'ID') ON CONFLICT (id) DO NOTHING`,
[tenantId],
);
const passwordHash = await bcrypt.hash('SeedAdongAdmin!', 10);
await ds.query(
`INSERT INTO users (id, tenant_id, email, name, password_hash, role)
VALUES (gen_random_uuid(), $1, 'admin@adong.local', 'Adong Admin', $2, 'admin')
ON CONFLICT DO NOTHING`,
[tenantId, passwordHash],
);
}
Add a npm run seed:dev script that imports + invokes this against the dev DB.
- [ ] Step 8: Run seed against dev DB
DATABASE_URL=<dev-conn> npm run seed:dev
- [ ] Step 9: Manual login test
Start the API: npm run start:dev. Then:
curl -i -X POST http://localhost:3010/api/v1/auth/login \
-H 'Content-Type: application/json' \
-H 'X-Tenant-Id: 00000000-0000-0000-0000-000000000001' \
-d '{"email":"admin@adong.local","password":"SeedAdongAdmin!"}'
Expected: HTTP 201 + Set-Cookie: connect.sid=... + {"user":{...}}.
git add -A
git commit -m "feat(auth): session-based auth for dashboard users + Adong admin seed"
git push origin main
Files:
- Create: src/modules/auth/strategies/device-jwt.strategy.ts
- Create: src/modules/auth/guards/device-auth.guard.ts
- Create: src/modules/auth/device-token.service.ts
- Test: test/modules/auth/device-token.service.spec.ts
npm install @nestjs/jwt passport-jwt
npm install -D @types/passport-jwt
- [ ] Step 2: Write the failing test for token issuance + verification
Create test/modules/auth/device-token.service.spec.ts:
import { JwtService } from '@nestjs/jwt';
import { DeviceTokenService } from '../../../src/modules/auth/device-token.service';
describe('DeviceTokenService', () => {
let svc: DeviceTokenService;
beforeEach(() => {
const jwt = new JwtService({ secret: 'test-secret' });
svc = new DeviceTokenService(jwt);
});
it('issues a token containing the device id and tenant id', () => {
const token = svc.issueForDevice({ deviceId: 'd1', tenantId: 't1', truckId: 'tr1' });
expect(typeof token).toBe('string');
const decoded = svc.verify(token);
expect(decoded.deviceId).toBe('d1');
expect(decoded.tenantId).toBe('t1');
expect(decoded.truckId).toBe('tr1');
});
it('rejects a tampered token', () => {
const token = svc.issueForDevice({ deviceId: 'd1', tenantId: 't1' });
const bad = token.replace(/.$/, '');
expect(() => svc.verify(bad)).toThrow();
});
});
- [ ] Step 3: Run, watch fail
npm test -- device-token
Create src/modules/auth/device-token.service.ts:
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
export interface DeviceTokenPayload {
deviceId: string;
tenantId: string;
truckId?: string;
iat?: number;
exp?: number;
}
@Injectable()
export class DeviceTokenService {
constructor(private readonly jwt: JwtService) {}
issueForDevice(payload: { deviceId: string; tenantId: string; truckId?: string }): string {
return this.jwt.sign(payload, { expiresIn: '90d' });
}
verify(token: string): DeviceTokenPayload {
return this.jwt.verify<DeviceTokenPayload>(token);
}
}
- [ ] Step 5: Add the JWT strategy + guard
Create src/modules/auth/strategies/device-jwt.strategy.ts:
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class DeviceJwtStrategy extends PassportStrategy(Strategy, 'device-jwt') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || 'dev-only-change-in-prod',
});
}
async validate(payload: any) {
return {
deviceId: payload.deviceId,
tenantId: payload.tenantId,
truckId: payload.truckId,
};
}
}
Create src/modules/auth/guards/device-auth.guard.ts:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class DeviceJwtAuthGuard extends AuthGuard('device-jwt') {}
- [ ] Step 6: Wire JwtModule in AuthModule, register strategy
Update src/modules/auth/auth.module.ts:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './strategies/session.strategy';
import { DeviceJwtStrategy } from './strategies/device-jwt.strategy';
import { DeviceTokenService } from './device-token.service';
import { User } from '../users/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dev-only-change-in-prod',
signOptions: { expiresIn: '90d' },
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, DeviceJwtStrategy, DeviceTokenService],
exports: [DeviceTokenService],
})
export class AuthModule {}
- [ ] Step 7: Add admin endpoint to provision a device + receive its token
Add to auth.controller.ts:
import { Body, Post, UseGuards } from '@nestjs/common';
import { SessionGuard } from './guards/session-auth.guard';
import { DeviceTokenService } from './device-token.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
// Constructor:
constructor(private readonly tokens: DeviceTokenService) {}
@UseGuards(SessionGuard)
@Post('device-tokens')
provision(
@CurrentTenant() tenantId: string,
@Body() body: { deviceId: string; truckId?: string },
) {
const token = this.tokens.issueForDevice({
deviceId: body.deviceId,
tenantId,
truckId: body.truckId,
});
return { token };
}
- [ ] Step 8: Add /api/v1/health-device — protected by device JWT
Add to app.controller.ts:
import { UseGuards } from '@nestjs/common';
import { DeviceJwtAuthGuard } from './modules/auth/guards/device-auth.guard';
@UseGuards(DeviceJwtAuthGuard)
@Get('api/v1/health-device')
deviceHealth() {
return { status: 'ok', context: 'device-authenticated' };
}
- [ ] Step 9: Smoke test the JWT path
Start API. Login as admin to get a session, then provision a device:
curl -X POST http://localhost:3010/api/v1/auth/device-tokens \
-H 'Content-Type: application/json' \
-H 'X-Tenant-Id: 00000000-0000-0000-0000-000000000001' \
-b cookie.txt \
-d '{"deviceId":"00000000-0000-0000-0000-0000000000aa"}'
Expected: {"token":"eyJ..."}. Save the token.
curl -i http://localhost:3010/api/v1/health-device \
-H "Authorization: Bearer <TOKEN>"
Expected: HTTP 200 with {"status":"ok","context":"device-authenticated"}.
git add -A
git commit -m "feat(auth): device-bound JWT for tablets + provisioning endpoint"
git push origin main
- [ ] Step 1: Open
https://cab.jofnd.ai/health in a browser
Expected: redirected to Cloudflare Access OAuth, login with josepheffendy@gmail.com (or jofnd.ai email), then sees the /health JSON.
- [ ] Step 2: Confirm device JWT path bypasses CF Access (it shouldn't)
curl -i https://cab.jofnd.ai/api/v1/health-device \
-H "Authorization: Bearer <TOKEN>"
Expected: HTTP 302 to CF Access (because tablets can't do interactive OAuth, this won't work). Decision: for the v1 pilot, the tablet API surface (/api/v1/shifts/*, /api/v1/events/*, /api/v1/sync/*, /api/v1/health-device) needs a separate subdomain (e.g. cab-api.jofnd.ai) WITHOUT CF Access — protected only by device JWT. Document this and create the second tunnel route in Sprint 2.
Add a TODO at the top of pilot-adong-aug2026.md Sprint 2 section: "Split CF Tunnel to two hostnames: cab.jofnd.ai (CF Access + dashboard + admin) vs cab-api.jofnd.ai (no CF Access, device-JWT only)."
- [ ] Step 3: Commit auth-related docs
Update sentinel-cab-api/README.md with auth model summary (session for dashboard via cab.jofnd.ai, JWT for tablets via cab-api.jofnd.ai). Commit.
Files:
- Create: ~/Code/sentinel-cab/sentinel-cab-app/ (entire repo)
- [ ] Step 1: Initialize Vite + React + TS
cd ~/Code/sentinel-cab/sentinel-cab-app
npm create vite@latest . -- --template react-ts
npm install
When prompted for "Current directory not empty," choose Ignore files and continue.
- [ ] Step 2: Update package.json identity
{
"name": "sentinel-cab-app",
"version": "0.1.0",
"description": "Sentinel_Cab in-cab app (Capacitor + React+Vite, Galaxy XCover7)"
}
- [ ] Step 3: Smoke-test in browser
npm run dev
Visit http://localhost:5173/. Expected: Vite + React default page renders.
git add -A
git commit -m "chore: initial Vite+React+TS scaffold"
git push origin main
Files:
- Modify: ~/Code/sentinel-cab/sentinel-cab-app/
- Create: ~/Code/sentinel-cab/sentinel-cab-app/android/
- [ ] Step 1: Install Capacitor core + CLI + Android platform
cd ~/Code/sentinel-cab/sentinel-cab-app
npm install @capacitor/core @capacitor/cli @capacitor/android
- [ ] Step 2: Initialize Capacitor
npx cap init "Sentinel Cab" "ai.jofnd.sentinelcab" --web-dir=dist
Expected: creates capacitor.config.ts.
- [ ] Step 3: Build the web bundle and add Android platform
npm run build
npx cap add android
Expected: android/ directory created with Android Studio project.
- [ ] Step 4: Configure capacitor.config.ts for v1 needs
Replace capacitor.config.ts content:
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'ai.jofnd.sentinelcab',
appName: 'Sentinel Cab',
webDir: 'dist',
server: {
androidScheme: 'https',
cleartext: false,
},
android: {
allowMixedContent: false,
overrideUserAgent: undefined,
},
};
export default config;
- [ ] Step 5: Build the APK in debug mode
npx cap sync android
npx cap open android
In Android Studio: Build → Build Bundle(s)/APK(s) → Build APK(s). Note path of generated APK (typically android/app/build/outputs/apk/debug/app-debug.apk).
- [ ] Step 6: Install on bench XCover7
Connect XCover7 via USB. Enable Developer Options + USB Debugging on the phone. Then:
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n ai.jofnd.sentinelcab/ai.jofnd.sentinelcab.MainActivity
Expected: default Vite + React page renders on the XCover7.
git add -A
git commit -m "feat: Capacitor + Android platform; APK installs on XCover7 bench"
git push origin main
Files:
- Create: src/screens/IdleScreen.tsx
- Create: src/components/StatusBar.tsx
- Create: src/styles/tokens.css
- Modify: src/App.tsx
- Test: src/screens/__tests__/IdleScreen.test.tsx
- [ ] Step 1: Install testing deps
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
Add to vite.config.ts:
/// <reference types="vitest" />
// in defineConfig: test: { environment: 'jsdom', globals: true, setupFiles: ['./src/setupTests.ts'] }
Create src/setupTests.ts:
import '@testing-library/jest-dom';
Add to package.json scripts: "test": "vitest".
- [ ] Step 2: Write failing test
Create src/screens/__tests__/IdleScreen.test.tsx:
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { IdleScreen } from '../IdleScreen';
describe('IdleScreen', () => {
it('shows the prompt and the truck plate', () => {
render(<IdleScreen truckPlate="DT-001" online={true} pendingSyncCount={0} />);
expect(screen.getByText(/tap your card/i)).toBeInTheDocument();
expect(screen.getByText('DT-001')).toBeInTheDocument();
});
it('shows offline indicator when not online', () => {
render(<IdleScreen truckPlate="DT-001" online={false} pendingSyncCount={3} />);
expect(screen.getByText(/offline/i)).toBeInTheDocument();
expect(screen.getByText(/3 pending/i)).toBeInTheDocument();
});
});
- [ ] Step 3: Run, watch fail
npm test -- IdleScreen
- [ ] Step 4: Add design tokens
Create src/styles/tokens.css:
:root {
--color-bg: #0b1a18;
--color-surface: #11241f;
--color-text: #f3f7f5;
--color-text-muted: #b6c8c2;
--color-accent: #4dd8a7;
--color-warn: #f4c75a;
--color-danger: #ef6a6a;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--tap-min: 64px;
--tap-comfy: 80px;
}
* { box-sizing: border-box; }
html, body, #root { margin: 0; padding: 0; height: 100%; background: var(--color-bg); color: var(--color-text); font-family: var(--font-sans); }
- [ ] Step 5: Implement IdleScreen
Create src/screens/IdleScreen.tsx:
import './IdleScreen.css';
export interface IdleScreenProps {
truckPlate: string;
online: boolean;
pendingSyncCount: number;
}
export function IdleScreen({ truckPlate, online, pendingSyncCount }: IdleScreenProps) {
return (
<main className="idle">
<header className="idle__header">
<span className="idle__truck">{truckPlate}</span>
<span className={`idle__net ${online ? 'is-online' : 'is-offline'}`}>
{online ? 'Online' : 'Offline'}
</span>
{pendingSyncCount > 0 && (
<span className="idle__sync">{pendingSyncCount} pending</span>
)}
</header>
<section className="idle__prompt">
<div className="idle__icon">🪪</div>
<h1 className="idle__title">Tap your card to start</h1>
<p className="idle__sub">Sentuh kartu Anda untuk memulai shift</p>
</section>
<footer className="idle__footer">
<time className="idle__clock">{new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}</time>
</footer>
</main>
);
}
Create src/screens/IdleScreen.css:
.idle { display: flex; flex-direction: column; height: 100vh; padding: 24px; }
.idle__header { display: flex; gap: 12px; align-items: center; font-size: 16px; }
.idle__truck { background: var(--color-surface); padding: 8px 16px; border-radius: 8px; font-weight: 700; color: var(--color-accent); }
.idle__net.is-online { color: var(--color-accent); }
.idle__net.is-offline { color: var(--color-warn); }
.idle__sync { background: var(--color-warn); color: var(--color-bg); padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 700; }
.idle__prompt { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.idle__icon { font-size: 96px; margin-bottom: 24px; }
.idle__title { font-size: 32px; margin: 0 0 12px; }
.idle__sub { font-size: 18px; color: var(--color-text-muted); margin: 0; }
.idle__footer { text-align: center; padding-top: 16px; }
.idle__clock { font-size: 20px; color: var(--color-text-muted); }
Update src/App.tsx:
import './styles/tokens.css';
import { IdleScreen } from './screens/IdleScreen';
export default function App() {
return <IdleScreen truckPlate="DT-DEV" online={true} pendingSyncCount={0} />;
}
- [ ] Step 6: Run test, watch pass
npm test -- IdleScreen
Expected: PASS.
- [ ] Step 7: Build APK and install on XCover7
npm run build
npx cap sync android
npx cap open android
# Build APK in Studio, then:
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n ai.jofnd.sentinelcab/.MainActivity
Expected: idle screen with "DT-DEV" plate, "Tap your card to start" + Bahasa subline, current time, online indicator.
git add -A
git commit -m "feat(idle-screen): v1 idle screen with truck plate, network state, sync queue indicator"
git push origin main
Files:
- Create: src/hooks/useRfidInput.ts
- Create: src/components/HiddenRfidInput.tsx
- Modify: src/screens/IdleScreen.tsx
- Test: src/hooks/__tests__/useRfidInput.test.ts
- [ ] Step 1: Write failing test for the hook
Create src/hooks/__tests__/useRfidInput.test.ts:
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRfidInput } from '../useRfidInput';
describe('useRfidInput', () => {
it('calls onUid when an enter-terminated UID is typed', () => {
const onUid = vi.fn();
const { result } = renderHook(() => useRfidInput({ onUid }));
act(() => {
result.current.feedKey({ key: '0', preventDefault: () => {} } as any);
result.current.feedKey({ key: '4', preventDefault: () => {} } as any);
result.current.feedKey({ key: 'A', preventDefault: () => {} } as any);
result.current.feedKey({ key: 'Enter', preventDefault: () => {} } as any);
});
expect(onUid).toHaveBeenCalledWith('04A');
});
it('debounces partial reads', () => {
const onUid = vi.fn();
const { result } = renderHook(() => useRfidInput({ onUid, idleResetMs: 500 }));
act(() => {
result.current.feedKey({ key: '0', preventDefault: () => {} } as any);
});
expect(onUid).not.toHaveBeenCalled();
});
});
- [ ] Step 2: Run, watch fail
npm test -- useRfidInput
- [ ] Step 3: Implement the hook
Create src/hooks/useRfidInput.ts:
import { useCallback, useEffect, useRef } from 'react';
export interface UseRfidInputOptions {
onUid: (uid: string) => void;
/**
* Idle reset timeout — if no key typed for this many ms, the buffer is cleared.
* Protects against stale keystrokes from supervisor poking the device.
*/
idleResetMs?: number;
}
export function useRfidInput({ onUid, idleResetMs = 250 }: UseRfidInputOptions) {
const bufferRef = useRef<string>('');
const timerRef = useRef<number | null>(null);
const reset = useCallback(() => {
bufferRef.current = '';
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const feedKey = useCallback(
(event: KeyboardEvent | React.KeyboardEvent<HTMLInputElement>) => {
// Allow letters/digits (DESFire UIDs are hex); ignore modifier keys.
const key = event.key;
if (key === 'Enter') {
if (bufferRef.current.length > 0) {
onUid(bufferRef.current);
}
reset();
return;
}
if (key.length !== 1) return; // ignore Tab, Shift, Backspace, etc.
if (!/^[0-9A-Fa-f]$/.test(key)) return; // hex chars only
bufferRef.current += key.toUpperCase();
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(reset, idleResetMs);
},
[onUid, idleResetMs, reset],
);
useEffect(() => () => reset(), [reset]);
return { feedKey };
}
- [ ] Step 4: Build a hidden input that always-on focus and feeds the hook
Create src/components/HiddenRfidInput.tsx:
import { useEffect, useRef } from 'react';
import { useRfidInput } from '../hooks/useRfidInput';
export interface HiddenRfidInputProps {
onUid: (uid: string) => void;
}
export function HiddenRfidInput({ onUid }: HiddenRfidInputProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const { feedKey } = useRfidInput({ onUid });
// Always-on focus — reader emulates keyboard, types into the focused field.
useEffect(() => {
const focusInput = () => inputRef.current?.focus();
focusInput();
const interval = window.setInterval(focusInput, 1000); // re-focus every 1s in case something stole focus
document.addEventListener('click', focusInput);
return () => {
window.clearInterval(interval);
document.removeEventListener('click', focusInput);
};
}, []);
return (
<input
ref={inputRef}
onKeyDown={feedKey}
// visually hidden but still focusable
style={{
position: 'fixed',
left: '-9999px',
opacity: 0,
height: 0,
width: 0,
}}
autoFocus
aria-hidden="true"
tabIndex={-1}
/>
);
}
- [ ] Step 5: Wire into IdleScreen
Update src/App.tsx:
import { useState } from 'react';
import './styles/tokens.css';
import { IdleScreen } from './screens/IdleScreen';
import { HiddenRfidInput } from './components/HiddenRfidInput';
export default function App() {
const [lastUid, setLastUid] = useState<string | null>(null);
return (
<>
<HiddenRfidInput onUid={setLastUid} />
<IdleScreen truckPlate="DT-DEV" online={true} pendingSyncCount={0} />
{lastUid && (
<div style={{
position: 'fixed', bottom: 16, left: 16, right: 16,
background: 'rgba(77, 216, 167, 0.2)', border: '1px solid var(--color-accent)',
padding: '12px 16px', borderRadius: 8, fontFamily: 'monospace', fontSize: 14,
}}>
Captured UID: <strong>{lastUid}</strong> (length {lastUid.length})
</div>
)}
</>
);
}
- [ ] Step 6: Run tests, build, install
npm test
npm run build
npx cap sync android
# Build APK in Studio, install on XCover7
- [ ] Step 7: Bench-test the RFID input
With XCover7 plugged into ACR122U via USB-OTG with PD passthrough:
1. Open the app — idle screen visible
2. Tap a DESFire EV3 card on the reader
3. Reader buzzes + LEDs green
4. App shows "Captured UID: 0419BA32A77380 (length 14)" — confirms 7-byte UID = 14 hex chars
Expected: UID appears within ~200ms of card tap. Try 5 different cards in succession to confirm reliable parsing + reset between taps.
If UID does not appear: check (a) is the reader showing visible LED + buzzer, (b) does Android recognize the OTG device (Settings → Connections → USB), (c) is the hidden input still focused (try clicking the screen), (d) is the reader actually in HID-wedge mode.
git add -A
git commit -m "feat(rfid): HID keyboard-wedge input handler with always-on focus + visible UID readout for bench testing"
git push origin main
Files:
- Create: ~/Code/sentinel-cab/sentinel-cab-app/docs/knox-kiosk-setup.md
- [ ] Step 1: Sign up for Samsung Knox Manage trial
Visit https://www.knox.samsung.com/. Create a Knox account using a business email. Start a 90-day trial of Knox Manage.
- [ ] Step 2: Enroll the bench XCover7 in Knox Manage
Per Knox Manage docs (which the sub-skill should fetch via the find-docs skill if needed): generate an enrollment QR, factory-reset the XCover7, scan QR during setup wizard.
- [ ] Step 3: Configure kiosk policy
In Knox Manage console:
- Create kiosk profile pinning ai.jofnd.sentinelcab as the only launchable app
- Disable Settings access, recent apps button, status bar pull-down
- Allow brightness control (drivers may need to adjust for sun/glare)
Apply to the bench XCover7.
- [ ] Step 4: Verify kiosk lock works
Reboot the XCover7 — should boot directly into Sentinel_Cab app with no Android home screen reachable.
- [ ] Step 5: Document the process
Create docs/knox-kiosk-setup.md capturing the enrollment QR generation steps, profile config, and rollback procedure (in case kiosk needs to be released for service).
git add -A
git commit -m "docs(knox): kiosk lock setup procedure for XCover7 production fleet"
git push origin main
Files:
- Create: ~/Documents/01_Local_Work/CoWork/Argus_IIOP/Sentinel_Cab/bench-test-results/2026-05-XX-glove-tap.md
- [ ] Step 1: Procure test gloves
Use industrial work gloves typical of mining operations — leather/synthetic with conductive fingertips if available, plain cotton/nitrile otherwise. 2–3 different glove types if possible.
- [ ] Step 2: Set up dim cab simulation
Indoor room with overhead lights off, single small lamp at 30° angle to simulate dim cab. XCover7 in desktop stand at typical dash angle.
- [ ] Step 3: Run 100-tap reliability test
For each glove type (and bare hand control):
- Open app, focus an "End Shift" mock button (1/3 width of screen at the bottom)
- Tap 100 times
- Record: first-tap success / second-tap success / fail
- Target: ≥95% first-tap success in any glove type
- [ ] Step 4: Document results
Write bench-test-results/2026-05-XX-glove-tap.md with:
- Date, tester, glove types tested
- Results table (gloves × first-tap success / second-tap / fail)
- Photos of test setup
- PASS/FAIL determination
- Any UX implications for buttons (size, spacing, timing)
cd ~/Documents/01_Local_Work/CoWork
git add Argus_IIOP/Sentinel_Cab/bench-test-results/
git commit -m "test(sentinel-cab): D-026 glove-tap reliability bench test results"
Files:
- Create: Argus_IIOP/Sentinel_Cab/bench-test-results/2026-05-XX-cradle.md
- [ ] Step 1: Order RAM Mounts X-Grip Quick Release
If not already in bench kit. Search Tokopedia: RAM Mounts X-Grip Quick Release. Get the ball-mount + base + cradle bundle for ~Rp 700k–1M.
- [ ] Step 2: Mount XCover7 in cradle
Confirm: positive lock (cradle clamps device, no slop), USB-C plug clears cradle frame for charging, secondary tether attachment point exists or can be added (e.g. coiled lanyard from cradle to XCover7 case).
- [ ] Step 3: Vibration simulation
Either: take to a workshop and bolt the cradle to a vibrating panel for 30 min; OR mount in a personal car and drive over rough roads for 30 min; OR use a cheap 12V vibrating massager bolted to a board for desktop vibration test.
Observe: does the XCover7 stay in the cradle, does the USB-C connection stay made, does Knox kiosk persist.
- [ ] Step 4: Document results
Write bench-test-results/2026-05-XX-cradle.md with:
- Cradle model + price + Tokopedia source
- Vibration test method
- Pass/fail observations
- Recommended cradle for pilot install (with brand + URL/SKU)
- [ ] Step 1: Prepare test setup
XCover7 in cradle, cable to ACR122U (representing realistic load), ACR122U connected (no cards being read). Park car in midday sun, windows up. Outside temp recorded.
- [ ] Step 2: Record start temp + state
Smartphone weather app for ambient. XCover7 battery temp via Settings → Battery → (or adb shell dumpsys battery). Record both.
- [ ] Step 3: Run app continuously for 4 hours
Idle screen left on. Periodic wakeups every 5 min via screen-on broadcast (or just watch and prevent sleep). Record temp every 30 min.
- [ ] Step 4: Watch for thermal throttling
Check if app remains responsive, battery temp stays below 50°C, no thermal-shutdown message. CPU throttling indicators via adb shell dumpsys thermalservice.
- [ ] Step 5: Document results
Write bench-test-results/2026-05-XX-thermal.md with: ambient temp profile, device temp profile, any throttling observed, PASS/FAIL.
- [ ] Step 1: Verify all Sprint 1 outcomes pass
Walk through the "Sprint 1 outcome" checklist (top of this doc). Each item must demonstrably work.
- [ ] Step 2: Record a 5-minute demo video
Screen + voice: navigate the dashboard auth flow (CF Access → session login → /me), provision a tablet token, hit /api/v1/health-device with the token. Then film the XCover7: idle screen, RFID tap, UID appears.
- [ ] Step 3: Write Sprint 1 retrospective
Create Argus_IIOP/Sentinel_Cab/sprint-retros/sprint-1-retro.md answering:
- What shipped vs planned (gap analysis)
- D-026 exit criteria results (all pass / any concerns)
- New decisions surfaced during sprint (added to decisions.md)
- Sprint 2 dependencies / risks
- Team capacity reality check
- [ ] Step 4: Update
decisions.md with any new decisions
If any architectural fork was resolved during Sprint 1 (repo structure, backend host, specific cradle vendor), capture as new D-NNN entries.
- [ ] Step 5: Kick off Sprint 2 plan writing
Trigger Sprint 2 detailed plan writing (the writing-plans skill again, scoped to Sprint 2: CRUD + shift lifecycle).
- [ ] Step 6: Commit demo + retro
cd ~/Documents/01_Local_Work/CoWork
git add Argus_IIOP/Sentinel_Cab/sprint-retros/ Argus_IIOP/Sentinel_Cab/decisions.md
git commit -m "docs(sentinel-cab): Sprint 1 retrospective + decisions update"
Each subsequent sprint will get its own detailed plan via the writing-plans skill at sprint kickoff. Outline below is task-level scope only.
- Admin CRUD modules: drivers, cards, trucks, geofences, assignments
- DTOs + class-validator + repository pattern
- Shift lifecycle endpoints:
start-attempt, end, events
- Eligibility engine (D-015 + D-022 + D-024) — primary/backup/unscheduled, multi-shift detection, breakdown/handover continuation lookups
shifts table migration with all D-022/D-024 columns
shift_events table + idempotent ingestion
- Tablet calls happy-path against backend
- Split CF Tunnel: cab.jofnd.ai (CF Access + dashboard) vs cab-api.jofnd.ai (no CF Access, JWT-only)
- Selfie capture flow on tablet (camera, stationary check, retake, EXIF)
- Selfie upload to R2;
photo_verifications table
- Risk-trigger engine (3 v1 rules): random ~15%, reassignment shift, prev-low-trust
- Exception detector: 9 v1 exception types
- State machine fully implemented; all 7 trust states reachable
- Continuation flows tested (breakdown + handover)
- Dashboard "Now" view with breakdown + handover counters
- Selfie review queue
- Exception inbox
- Shift detail with timeline + continuation chain panel
- Down-sync delta endpoint + tablet local cache (SQLite via Capacitor)
- Up-sync batch endpoint + tablet event queue
- Haul route mapping (D-025) — half-day satellite-imagery trace
- Geofence seed data: Adong base camp + loading + dumping polygons
- Card face design locked (D-011)
- Production APK build + Knox kiosk lock testing
- End-to-end test: full shift cycle online + offline + reconnect
- Performance pass: cold start <3s, RFID-tap-to-active <2s, dashboard "Now" view <1s
- Security review
- Audit log coverage check
- Card-print order placed
- UAT script (20+ scenarios)
- Joseph + 1 tester runs full UAT
- Bug fixes
- Install rehearsal on 1 mocked truck
- Training material: driver flyer, supervisor walkthrough, admin training
- Pilot hardware delivered to Adong
- Install trucks 1–5 (Week 13, 2/day)
- Install trucks 6–10 (Week 14)
- Card distribution + driver briefings + supervisor onboarding
- GO-LIVE
- Daily on-call support (14 days)
- Daily exception inbox flush with supervisor
- Week-1 retro (Aug 24)
- Week-2 retro (Aug 31) — first qualitative signals
1. Spec coverage — every D-001 → D-026 decision is reflected somewhere in the plan:
- D-001 → D-008: foundation tasks (1–8)
- D-009 → D-011: card issuance (Sprint 2 admin) — outline note in Sprint 2
- D-010: DESFire EV3 in bench kit (Task 1) and entity defaults (Task 13)
- D-012, D-013: standalone API + multi-tenant (Tasks 6, 7, 10–17)
- D-014: R2 bucket creation (Task 4) — code lands Sprint 3
- D-015: assignments entity (Task 16) — eligibility engine Sprint 2
- D-017: dual auth (Tasks 19–21) + cab.jofnd.ai vs cab-api.jofnd.ai split (Sprint 2 outlined)
- D-022 / D-024: schema columns prepared in Sprint 2 outline
- D-023 / D-025: zone enum in Task 17; haul route mapping in Sprint 4 outline
- D-026: XCover7 in Task 1 + 22 + 23 + 27–29 + Task 25 entity name
2. Placeholder scan — none. All TBDs are explicitly carried as Sprint 2+ outline items, not in-Sprint-1 work.
3. Type consistency — entity names match across tasks: Driver / RfidCard / Truck / TabletDevice / RfidReader / TruckDriverAssignment / Geofence / User / Tenant / AuditLog. tenantId lowercase camelCase in TS, tenant_id snake_case in SQL — consistent.