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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ async function getActiveSession(plate: string): Promise<SessionRecord | null> {
return rows[0] ? mapSession(rows[0]) : null
}

async function getSessionState(sessionId: string): Promise<SessionState | null> {
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<SessionRecord[]> {
const { rows } = await pool.query(
`SELECT * FROM sessions
Expand Down Expand Up @@ -1332,6 +1343,7 @@ export const db = {
deactivateDriver,
createSession,
getActiveSession,
getSessionState,
getActiveSessionsByLot,
transitionSession,
settleSessionAfterVerified,
Expand Down
33 changes: 28 additions & 5 deletions apps/api/src/routes/sessions.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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' })
Expand Down
159 changes: 133 additions & 26 deletions apps/api/test/routes/sessions.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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'

vi.mock('../../src/db', () => ({
db: {
getActiveSession: vi.fn(),
getSessionState: vi.fn(),
getSessionHistory: vi.fn(),
getSessionTimeline: vi.fn(),
},
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' },
},
])
})
})
})
Loading
Loading