From 63d718de2cb132e9433623b9c82800aa43ed28a1 Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sun, 8 Mar 2026 19:34:29 +0200 Subject: [PATCH 1/3] feat(api): add session timeline endpoint with validation and summaries Expose lifecycle timeline events through the sessions API with UUID validation, session existence checks, internal-safe access control, and eventCount summaries, plus tests and docs for deterministic ordering and response contract. Made-with: Cursor --- README.md | 19 ++ apps/api/src/db/queries.ts | 12 + apps/api/src/routes/sessions.ts | 30 ++- apps/api/test/routes/sessions.test.ts | 157 ++++++++++-- docs/EVENT_TAXONOMY.md | 226 ++++++++++++++++++ ...ECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md | 54 +++-- 6 files changed, 450 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 6fc7f16..578e983 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,25 @@ 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", + "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..2fe9a5a 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 sessionExists(sessionId: string): Promise { + const { rows } = await pool.query( + `SELECT 1 + FROM sessions + WHERE id = $1::uuid + LIMIT 1`, + [sessionId], + ) + return rows.length > 0 +} + async function getActiveSessionsByLot(lotId: string): Promise { const { rows } = await pool.query( `SELECT * FROM sessions @@ -1332,6 +1343,7 @@ export const db = { deactivateDriver, createSession, getActiveSession, + sessionExists, getActiveSessionsByLot, transitionSession, settleSessionAfterVerified, diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 3d10192..2503f05 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -1,9 +1,16 @@ -import { Router } from 'express' +import { Router, type Request } from 'express' import { normalizePlate } from '@parker/core' import { db } from '../db' 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 +45,29 @@ 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 exists = await db.sessionExists(sessionId) + if (!exists) { + return res.status(404).json({ error: 'Session not found' }) + } + const timeline = await db.getSessionTimeline(sessionId, limit) + res.json({ + sessionId, + 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..9e290da 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(), + sessionExists: 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,137 @@ 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.sessionExists).mockResolvedValue(true) 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.sessionExists).toHaveBeenCalledWith(sessionId) + expect(db.getSessionTimeline).toHaveBeenCalledWith(sessionId, 500) + expect(res.body).toEqual({ + sessionId, + 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.sessionExists).mockResolvedValue(true) + 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.sessionExists).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.sessionExists).not.toHaveBeenCalled() + expect(db.getSessionTimeline).not.toHaveBeenCalled() + }) + + it('returns 404 for missing session', async () => { + vi.mocked(db.sessionExists).mockResolvedValue(false) + 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.sessionExists).mockResolvedValue(true) 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, eventCount: 0, events: [] }) + }) + + it('preserves db event order when timestamps are identical', async () => { + vi.mocked(db.sessionExists).mockResolvedValue(true) + 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.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..c35fa77 100644 --- a/docs/EVENT_TAXONOMY.md +++ b/docs/EVENT_TAXONOMY.md @@ -0,0 +1,226 @@ + + +# 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", + "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..b63808e 100644 --- a/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md +++ b/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md @@ -249,28 +249,48 @@ 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", + "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 From 64e4e9cb15ebf6bfb44e0a0dd063c2d8284ecee3 Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sun, 8 Mar 2026 19:40:08 +0200 Subject: [PATCH 2/3] feat(api): include current session state in timeline response Add current session state snapshot to the timeline endpoint by reading the sessions row, preserving 404 semantics for unknown sessions and updating tests/docs to reflect the enriched response payload. Made-with: Cursor --- README.md | 1 + apps/api/src/db/queries.ts | 8 +++---- apps/api/src/routes/sessions.ts | 5 +++-- apps/api/test/routes/sessions.test.ts | 22 ++++++++++--------- docs/EVENT_TAXONOMY.md | 1 + ...ECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md | 1 + 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 578e983..8735e54 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,7 @@ Timeline response shape: ```json { "sessionId": "11111111-1111-4111-8111-111111111111", + "state": "payment_required", "eventCount": 2, "events": [ { diff --git a/apps/api/src/db/queries.ts b/apps/api/src/db/queries.ts index 2fe9a5a..9587498 100644 --- a/apps/api/src/db/queries.ts +++ b/apps/api/src/db/queries.ts @@ -97,15 +97,15 @@ async function getActiveSession(plate: string): Promise { return rows[0] ? mapSession(rows[0]) : null } -async function sessionExists(sessionId: string): Promise { +async function getSessionState(sessionId: string): Promise { const { rows } = await pool.query( - `SELECT 1 + `SELECT status FROM sessions WHERE id = $1::uuid LIMIT 1`, [sessionId], ) - return rows.length > 0 + return rows[0] ? normalizeSessionState(rows[0].status) : null } async function getActiveSessionsByLot(lotId: string): Promise { @@ -1343,7 +1343,7 @@ export const db = { deactivateDriver, createSession, getActiveSession, - sessionExists, + getSessionState, getActiveSessionsByLot, transitionSession, settleSessionAfterVerified, diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 2503f05..ad24bf2 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -54,13 +54,14 @@ sessionsRouter.get('/:sessionId/timeline', async (req, res) => { const rawLimit = parseInt(req.query.limit as string) const limit = !isNaN(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 1000) : 500 const sessionId = req.params.sessionId - const exists = await db.sessionExists(sessionId) - if (!exists) { + const state = await db.getSessionState(sessionId) + if (!state) { return res.status(404).json({ error: 'Session not found' }) } const timeline = await db.getSessionTimeline(sessionId, limit) res.json({ sessionId, + state, eventCount: timeline.length, events: timeline.map((event) => ({ eventType: event.eventType, diff --git a/apps/api/test/routes/sessions.test.ts b/apps/api/test/routes/sessions.test.ts index 9e290da..9df49f1 100644 --- a/apps/api/test/routes/sessions.test.ts +++ b/apps/api/test/routes/sessions.test.ts @@ -6,7 +6,7 @@ import { sessionsRouter } from '../../src/routes/sessions' vi.mock('../../src/db', () => ({ db: { getActiveSession: vi.fn(), - sessionExists: vi.fn(), + getSessionState: vi.fn(), getSessionHistory: vi.fn(), getSessionTimeline: vi.fn(), }, @@ -116,7 +116,7 @@ describe('sessions routes', () => { const sessionId = '11111111-1111-4111-8111-111111111111' it('returns ordered timeline events with default limit', async () => { - vi.mocked(db.sessionExists).mockResolvedValue(true) + vi.mocked(db.getSessionState).mockResolvedValue('payment_required') vi.mocked(db.getSessionTimeline).mockResolvedValue([ { id: 'evt-1', @@ -138,10 +138,11 @@ describe('sessions routes', () => { const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) expect(res.status).toBe(200) - expect(db.sessionExists).toHaveBeenCalledWith(sessionId) + expect(db.getSessionState).toHaveBeenCalledWith(sessionId) expect(db.getSessionTimeline).toHaveBeenCalledWith(sessionId, 500) expect(res.body).toEqual({ sessionId, + state: 'payment_required', eventCount: 2, events: [ { @@ -159,7 +160,7 @@ describe('sessions routes', () => { }) it('respects and caps timeline limit', async () => { - vi.mocked(db.sessionExists).mockResolvedValue(true) + vi.mocked(db.getSessionState).mockResolvedValue('active') vi.mocked(db.getSessionTimeline).mockResolvedValue([]) const app = createApp() @@ -175,7 +176,7 @@ describe('sessions routes', () => { const res = await request(app).get('/api/sessions/not-a-uuid/timeline') expect(res.status).toBe(400) - expect(db.sessionExists).not.toHaveBeenCalled() + expect(db.getSessionState).not.toHaveBeenCalled() expect(db.getSessionTimeline).not.toHaveBeenCalled() }) @@ -185,12 +186,12 @@ describe('sessions routes', () => { const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) expect(res.status).toBe(401) - expect(db.sessionExists).not.toHaveBeenCalled() + expect(db.getSessionState).not.toHaveBeenCalled() expect(db.getSessionTimeline).not.toHaveBeenCalled() }) it('returns 404 for missing session', async () => { - vi.mocked(db.sessionExists).mockResolvedValue(false) + vi.mocked(db.getSessionState).mockResolvedValue(null) const app = createApp() const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) @@ -199,17 +200,17 @@ describe('sessions routes', () => { }) it('returns 200 with empty events for existing session with no timeline rows', async () => { - vi.mocked(db.sessionExists).mockResolvedValue(true) + 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, eventCount: 0, events: [] }) + expect(res.body).toEqual({ sessionId, state: 'active', eventCount: 0, events: [] }) }) it('preserves db event order when timestamps are identical', async () => { - vi.mocked(db.sessionExists).mockResolvedValue(true) + vi.mocked(db.getSessionState).mockResolvedValue('closed') vi.mocked(db.getSessionTimeline).mockResolvedValue([ { id: '00000000-0000-4000-8000-0000000000b2', @@ -231,6 +232,7 @@ describe('sessions routes', () => { const res = await request(app).get(`/api/sessions/${sessionId}/timeline`) expect(res.status).toBe(200) + expect(res.body.state).toBe('closed') expect(res.body.eventCount).toBe(2) expect(res.body.events).toEqual([ { diff --git a/docs/EVENT_TAXONOMY.md b/docs/EVENT_TAXONOMY.md index c35fa77..68486ae 100644 --- a/docs/EVENT_TAXONOMY.md +++ b/docs/EVENT_TAXONOMY.md @@ -169,6 +169,7 @@ Example response: ```json { "sessionId": "11111111-1111-4111-8111-111111111111", + "state": "payment_required", "eventCount": 2, "events": [ { diff --git a/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md b/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md index b63808e..bada56b 100644 --- a/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md +++ b/docs/LIFECYCLE_OBSERVABILITY_ANDTIMELINE_SPRINT.md @@ -251,6 +251,7 @@ GET /api/sessions/:sessionId/timeline ``` { "sessionId": "11111111-1111-4111-8111-111111111111", + "state": "payment_required", "eventCount": 2, "events": [ { From 04149effd8218097ac56368c82f282628796b01c Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sun, 8 Mar 2026 19:42:12 +0200 Subject: [PATCH 3/3] chore(api): log timeline fetch requests with event counts Emit a structured timeline.fetch log on successful timeline reads so operators can trace session timeline lookups and returned event volumes. Made-with: Cursor --- apps/api/src/routes/sessions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index ad24bf2..555e575 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -2,6 +2,7 @@ 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 @@ -59,6 +60,7 @@ sessionsRouter.get('/:sessionId/timeline', async (req, res) => { 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,