diff --git a/README.md b/README.md index 6fc7f16..8735e54 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,26 @@ parker-app/ | ------ | ------------------------------ | -------------------------- | | GET | `/api/sessions/active/:plate` | Get active parking session | | GET | `/api/sessions/history/:plate` | Get session history | +| GET | `/api/sessions/:sessionId/timeline` | Get ordered lifecycle timeline events (`x-gate-api-key` required when configured) | + +Timeline response shape: + +```json +{ + "sessionId": "11111111-1111-4111-8111-111111111111", + "state": "payment_required", + "eventCount": 2, + "events": [ + { + "eventType": "SESSION.CREATED", + "createdAt": "2026-03-08T12:00:00Z", + "metadata": { + "lotId": "lot_1" + } + } + ] +} +``` ### Gate API diff --git a/apps/api/src/db/queries.ts b/apps/api/src/db/queries.ts index 2015a77..9587498 100644 --- a/apps/api/src/db/queries.ts +++ b/apps/api/src/db/queries.ts @@ -97,6 +97,17 @@ async function getActiveSession(plate: string): Promise { return rows[0] ? mapSession(rows[0]) : null } +async function getSessionState(sessionId: string): Promise { + const { rows } = await pool.query( + `SELECT status + FROM sessions + WHERE id = $1::uuid + LIMIT 1`, + [sessionId], + ) + return rows[0] ? normalizeSessionState(rows[0].status) : null +} + async function getActiveSessionsByLot(lotId: string): Promise { const { rows } = await pool.query( `SELECT * FROM sessions @@ -1332,6 +1343,7 @@ export const db = { deactivateDriver, createSession, getActiveSession, + getSessionState, getActiveSessionsByLot, transitionSession, settleSessionAfterVerified, diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 3d10192..555e575 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -1,9 +1,17 @@ -import { Router } from 'express' +import { Router, type Request } from 'express' import { normalizePlate } from '@parker/core' import { db } from '../db' +import { logger } from '../services/observability' export const sessionsRouter = Router() +const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function hasTimelineAccess(req: Request): boolean { + const expectedApiKey = process.env.SESSION_TIMELINE_API_KEY || process.env.GATE_API_KEY + if (!expectedApiKey) return true + return req.header('x-gate-api-key') === expectedApiKey +} // GET /api/sessions/active/:plate — Get active parking session sessionsRouter.get('/active/:plate', async (req, res) => { @@ -38,16 +46,31 @@ sessionsRouter.get('/history/:plate', async (req, res) => { // GET /api/sessions/:sessionId/timeline — Get ordered lifecycle event timeline sessionsRouter.get('/:sessionId/timeline', async (req, res) => { try { + if (!UUID_V4_REGEX.test(req.params.sessionId)) { + return res.status(400).json({ error: 'Invalid sessionId format' }) + } + if (!hasTimelineAccess(req)) { + return res.status(401).json({ error: 'Unauthorized' }) + } const rawLimit = parseInt(req.query.limit as string) const limit = !isNaN(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 1000) : 500 - const timeline = await db.getSessionTimeline(req.params.sessionId, limit) - res.json( - timeline.map((event) => ({ + const sessionId = req.params.sessionId + const state = await db.getSessionState(sessionId) + if (!state) { + return res.status(404).json({ error: 'Session not found' }) + } + const timeline = await db.getSessionTimeline(sessionId, limit) + logger.info('timeline.fetch', { sessionId, eventCount: timeline.length }) + res.json({ + sessionId, + state, + eventCount: timeline.length, + events: timeline.map((event) => ({ eventType: event.eventType, createdAt: event.timestamp, metadata: event.metadata ?? {}, })), - ) + }) } catch (error) { console.error('Failed to get session timeline:', error) res.status(500).json({ error: 'Failed to get session timeline' }) diff --git a/apps/api/test/routes/sessions.test.ts b/apps/api/test/routes/sessions.test.ts index f3b0e39..9df49f1 100644 --- a/apps/api/test/routes/sessions.test.ts +++ b/apps/api/test/routes/sessions.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import express from 'express' import request from 'supertest' import { sessionsRouter } from '../../src/routes/sessions' @@ -6,6 +6,7 @@ import { sessionsRouter } from '../../src/routes/sessions' vi.mock('../../src/db', () => ({ db: { getActiveSession: vi.fn(), + getSessionState: vi.fn(), getSessionHistory: vi.fn(), getSessionTimeline: vi.fn(), }, @@ -21,7 +22,26 @@ function createApp() { } describe('sessions routes', () => { - beforeEach(() => vi.clearAllMocks()) + const originalTimelineApiKey = process.env.SESSION_TIMELINE_API_KEY + const originalGateApiKey = process.env.GATE_API_KEY + + beforeEach(() => { + vi.clearAllMocks() + delete process.env.SESSION_TIMELINE_API_KEY + delete process.env.GATE_API_KEY + }) + afterEach(() => { + if (originalTimelineApiKey === undefined) { + delete process.env.SESSION_TIMELINE_API_KEY + } else { + process.env.SESSION_TIMELINE_API_KEY = originalTimelineApiKey + } + if (originalGateApiKey === undefined) { + delete process.env.GATE_API_KEY + } else { + process.env.GATE_API_KEY = originalGateApiKey + } + }) describe('GET /api/sessions/active/:plate', () => { it('returns active session', async () => { @@ -93,52 +113,139 @@ describe('sessions routes', () => { }) describe('GET /api/sessions/:sessionId/timeline', () => { + const sessionId = '11111111-1111-4111-8111-111111111111' + it('returns ordered timeline events with default limit', async () => { + vi.mocked(db.getSessionState).mockResolvedValue('payment_required') vi.mocked(db.getSessionTimeline).mockResolvedValue([ { - id: 1, - sessionId: 's1', - eventType: 'SESSION_CREATED', + id: 'evt-1', + sessionId, + eventType: 'SESSION.CREATED', timestamp: new Date('2026-03-07T09:11:02.000Z'), metadata: { plateNumber: '1234567' }, } as any, { - id: 2, - sessionId: 's1', - eventType: 'SESSION_CLOSED', + id: 'evt-2', + sessionId, + eventType: 'SESSION.CLOSED', timestamp: new Date('2026-03-07T09:18:46.000Z'), metadata: {}, } as any, ]) const app = createApp() - const res = await request(app).get('/api/sessions/s1/timeline') + const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) expect(res.status).toBe(200) - expect(db.getSessionTimeline).toHaveBeenCalledWith('s1', 500) - expect(res.body).toEqual([ - { - eventType: 'SESSION_CREATED', - createdAt: '2026-03-07T09:11:02.000Z', - metadata: { plateNumber: '1234567' }, - }, - { - eventType: 'SESSION_CLOSED', - createdAt: '2026-03-07T09:18:46.000Z', - metadata: {}, - }, - ]) + expect(db.getSessionState).toHaveBeenCalledWith(sessionId) + expect(db.getSessionTimeline).toHaveBeenCalledWith(sessionId, 500) + expect(res.body).toEqual({ + sessionId, + state: 'payment_required', + eventCount: 2, + events: [ + { + eventType: 'SESSION.CREATED', + createdAt: '2026-03-07T09:11:02.000Z', + metadata: { plateNumber: '1234567' }, + }, + { + eventType: 'SESSION.CLOSED', + createdAt: '2026-03-07T09:18:46.000Z', + metadata: {}, + }, + ], + }) }) it('respects and caps timeline limit', async () => { + vi.mocked(db.getSessionState).mockResolvedValue('active') + vi.mocked(db.getSessionTimeline).mockResolvedValue([]) + const app = createApp() + + await request(app).get(`/api/sessions/${sessionId}/timeline?limit=1500`) + expect(db.getSessionTimeline).toHaveBeenCalledWith(sessionId, 1000) + + await request(app).get(`/api/sessions/${sessionId}/timeline?limit=10`) + expect(db.getSessionTimeline).toHaveBeenCalledWith(sessionId, 10) + }) + + it('returns 400 for malformed sessionId', async () => { + const app = createApp() + const res = await request(app).get('/api/sessions/not-a-uuid/timeline') + + expect(res.status).toBe(400) + expect(db.getSessionState).not.toHaveBeenCalled() + expect(db.getSessionTimeline).not.toHaveBeenCalled() + }) + + it('returns 401 when timeline internal API key is configured but missing', async () => { + process.env.SESSION_TIMELINE_API_KEY = 'internal-key-1' + const app = createApp() + const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) + + expect(res.status).toBe(401) + expect(db.getSessionState).not.toHaveBeenCalled() + expect(db.getSessionTimeline).not.toHaveBeenCalled() + }) + + it('returns 404 for missing session', async () => { + vi.mocked(db.getSessionState).mockResolvedValue(null) + const app = createApp() + const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) + + expect(res.status).toBe(404) + expect(db.getSessionTimeline).not.toHaveBeenCalled() + }) + + it('returns 200 with empty events for existing session with no timeline rows', async () => { + vi.mocked(db.getSessionState).mockResolvedValue('active') vi.mocked(db.getSessionTimeline).mockResolvedValue([]) const app = createApp() + const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ sessionId, state: 'active', eventCount: 0, events: [] }) + }) + + it('preserves db event order when timestamps are identical', async () => { + vi.mocked(db.getSessionState).mockResolvedValue('closed') + vi.mocked(db.getSessionTimeline).mockResolvedValue([ + { + id: '00000000-0000-4000-8000-0000000000b2', + sessionId, + eventType: 'POLICY.GRANT_ISSUED', + timestamp: new Date('2026-03-07T09:11:02.000Z'), + metadata: { grantId: 'grant-2' }, + } as any, + { + id: '00000000-0000-4000-8000-0000000000a1', + sessionId, + eventType: 'SESSION.CREATED', + timestamp: new Date('2026-03-07T09:11:02.000Z'), + metadata: { lotId: 'LOT-1' }, + } as any, + ]) - await request(app).get('/api/sessions/s1/timeline?limit=1500') - expect(db.getSessionTimeline).toHaveBeenCalledWith('s1', 1000) + const app = createApp() + const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) - await request(app).get('/api/sessions/s1/timeline?limit=10') - expect(db.getSessionTimeline).toHaveBeenCalledWith('s1', 10) + expect(res.status).toBe(200) + expect(res.body.state).toBe('closed') + expect(res.body.eventCount).toBe(2) + expect(res.body.events).toEqual([ + { + eventType: 'POLICY.GRANT_ISSUED', + createdAt: '2026-03-07T09:11:02.000Z', + metadata: { grantId: 'grant-2' }, + }, + { + eventType: 'SESSION.CREATED', + createdAt: '2026-03-07T09:11:02.000Z', + metadata: { lotId: 'LOT-1' }, + }, + ]) }) }) }) diff --git a/docs/EVENT_TAXONOMY.md b/docs/EVENT_TAXONOMY.md index e69de29..68486ae 100644 --- a/docs/EVENT_TAXONOMY.md +++ b/docs/EVENT_TAXONOMY.md @@ -0,0 +1,227 @@ + + +# Parker Event Taxonomy + +Purpose: Define the canonical lifecycle events emitted by the Parker system. + +Events are append-only records that describe what happened during a parking session lifecycle. + +They are used for: + +• observability +• debugging +• dispute resolution +• analytics +• autonomous agent reasoning + +--- + +# Event Naming Convention + +All events follow this structure: + +ENTITY.ACTION + +Examples: + +SESSION.CREATED +POLICY.GRANT_ISSUED +PAYMENT.DECISION_CREATED +SETTLEMENT.VERIFIED + +Rules: + +• Uppercase +• Dot separated +• Immutable (never rename once published) +• Append-only (events are never modified) + +--- + +# Base Event Schema + +Every event shares the same base metadata. + +Example: + +{ + "event": "SETTLEMENT.VERIFIED", + "timestamp": "2026-03-01T12:00:00Z", + "sessionId": "...", + "lotId": "...", + "vehicleId": "...", + "policyHash": "...", + "decisionId": "...", + "rail": "xrpl", + "asset": "RLUSD", + "details": {} +} + +Fields: + +sessionId +lotId +vehicleId +timestamp +policyHash (optional) +decisionId (optional) +rail (optional) +asset (optional) +details (free-form JSON) + +--- + +# Core Session Lifecycle + +These are the minimal lifecycle events that must exist for every session. + +SESSION.CREATED +SESSION.CLOSED +SESSION.CANCELLED + +--- + +# Entry / Gate Events + +ENTRY.PLATE_DETECTED +ENTRY.SCAN_FAILED +ENTRY.POLICY_EVALUATED +ENTRY.DENIED + +--- + +# Policy Layer Events + +POLICY.GRANT_ISSUED +POLICY.GRANT_DENIED +POLICY.DECISION_CREATED +POLICY.APPROVAL_REQUIRED + +--- + +# Payment Events + +PAYMENT.QUOTE_CREATED +PAYMENT.OPTION_SELECTED +PAYMENT.DECISION_CREATED + +--- + +# Settlement Events + +SETTLEMENT.SUBMITTED +SETTLEMENT.VERIFIED +SETTLEMENT.REJECTED +SETTLEMENT.REPLAY_DETECTED + +--- + +# XRPL Specific Events + +XRPL.INTENT_CREATED +XRPL.INTENT_REJECTED +XRPL.TX_VERIFIED +XRPL.TX_INVALID + +--- + +# Stripe Events + +STRIPE.CHECKOUT_CREATED +STRIPE.PAYMENT_CONFIRMED +STRIPE.WEBHOOK_RECEIVED + +--- + +# Minimal Implementation Set + +The first implementation phase should emit only these events: + +SESSION.CREATED +POLICY.GRANT_ISSUED +PAYMENT.DECISION_CREATED +SETTLEMENT.VERIFIED +SESSION.CLOSED + +These form the canonical session timeline. + +--- + +# Example Timeline + +SESSION.CREATED +POLICY.GRANT_ISSUED +PAYMENT.DECISION_CREATED +XRPL.INTENT_CREATED +SETTLEMENT.VERIFIED +SESSION.CLOSED + +--- + +# Timeline API Example + +Endpoint: + +`GET /api/sessions/:sessionId/timeline` + +Example response: + +```json +{ + "sessionId": "11111111-1111-4111-8111-111111111111", + "state": "payment_required", + "eventCount": 2, + "events": [ + { + "eventType": "SESSION.CREATED", + "createdAt": "2026-03-08T12:00:00Z", + "metadata": { + "lotId": "lot_1", + "vehicleId": "veh_1", + "plateNumber": "1234567" + } + }, + { + "eventType": "POLICY.GRANT_ISSUED", + "createdAt": "2026-03-08T12:00:01Z", + "metadata": { + "grantId": "grant_1", + "policyHash": "abc..." + } + } + ] +} +``` + +Current query options: + +- `?limit=` is supported +- `?type=` filtering is intentionally deferred for a later iteration + +--- + +# Design Principles + +1. Events are append-only. + +2. Events are immutable once emitted. + +3. Events describe facts, not state. + +4. State machines should be derived from event sequences. + +5. Events should never depend on application logs. + +--- + +# Future Extensions + +Possible additional event domains: + +VEHICLE.* +LOT.* +POLICY.* updates +RECONCILIATION.* +FRAUD.* detection + +These are intentionally excluded from the MVP lifecycle. \ No newline at end of file diff --git a/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md b/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md index e85b2e2..bada56b 100644 --- a/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md +++ b/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md @@ -249,28 +249,49 @@ GET /api/sessions/:sessionId/timeline ### Response ``` -[ - { - "eventType": "SESSION.CREATED", - "createdAt": "...", - "metadata": { - "lotId": "lot-1", - "vehicleId": "veh-1" - } - }, - { - "eventType": "POLICY.GRANT_ISSUED", - "createdAt": "...", - "metadata": { - "grantId": "grant-1", - "policyHash": "..." +{ + "sessionId": "11111111-1111-4111-8111-111111111111", + "state": "payment_required", + "eventCount": 2, + "events": [ + { + "eventType": "SESSION.CREATED", + "createdAt": "...", + "metadata": { + "lotId": "lot-1", + "vehicleId": "veh-1" + } + }, + { + "eventType": "POLICY.GRANT_ISSUED", + "createdAt": "...", + "metadata": { + "grantId": "grant-1", + "policyHash": "..." + } } - } -] + ] +} ``` Events should be returned in chronological order. +Validation behavior: + +- malformed `sessionId` (non-UUID): `400` +- unknown session: `404` +- existing session with no events yet: `200` with `"events": []` + +Current query capabilities: + +- supports `?limit=` (capped to 1000, default 500) +- event-type filtering (for example `?type=SETTLEMENT.VERIFIED`) is planned as a future extension + +Authorization behavior: + +- timeline is internal/operator access +- if `SESSION_TIMELINE_API_KEY` (or `GATE_API_KEY`) is configured, caller must send matching `x-gate-api-key` + --- # 8. Debugging Benefits