feat(api): GET /api/events endpoint + OpenAPI

Latest game events, newest first; auto-initializes like the other reads.
?limit= defaults to 50 (cap 200), non-integer/out-of-range → 400. OpenAPI
spec gains the path + Event schema (14 -> 15 paths).
This commit is contained in:
2026-09-29 02:45:38 +00:00
parent 8d678d73ce
commit 21abf8768f
3 changed files with 83 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import { getGameDb } from '@/lib/db/client'
import { ensureSave } from '@/lib/game/save'
import { EventError, getEvents, parseEventLimit } from '@/lib/game/events'
// The latest game events, newest first; auto-initializes on first hit.
export async function GET(req: Request) {
const db = await getGameDb()
await ensureSave(db) // getEvents assumes a save exists
let limit: number
try {
limit = parseEventLimit(new URL(req.url).searchParams.get('limit'))
} catch (err) {
if (err instanceof EventError) {
return Response.json({ error: err.message }, { status: err.status })
}
throw err
}
return Response.json(await getEvents(db, limit))
}
+17
View File
@@ -6,6 +6,7 @@ describe('OpenAPI spec', () => {
it('documents every public endpoint', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/crew',
'/api/events',
'/api/explore',
'/api/jump',
'/api/missions',
@@ -77,6 +78,22 @@ describe('OpenAPI spec', () => {
}
});
it('describes GET /api/events → Event[] with a limit query param and a 400 error response', () => {
const events = openApiSpec.paths['/api/events'].get.responses[200].content['application/json'].schema;
expect(events.type).toBe('array');
expect((events.items as { $ref: string }).$ref).toBe('#/components/schemas/Event');
const props = openApiSpec.components.schemas.Event.properties;
expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']);
expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']);
const get = openApiSpec.paths['/api/events'].get;
const limit = (get.parameters ?? []).find((p) => p.name === 'limit');
expect(limit?.in).toBe('query');
expect(limit?.required).toBe(false);
expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error');
});
it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => {
const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema;
expect(crew.type).toBe('array');
+45
View File
@@ -523,6 +523,40 @@ export const openApiSpec = {
},
},
},
'/api/events': {
get: {
summary: 'Event log',
description: "The save's game events, newest first — every action (new game, jump, scan, research, ship work, mission) appends one. Auto-initializes a new game when no save exists yet.",
operationId: 'getEvents',
parameters: [
{
name: 'limit',
in: 'query',
required: false,
schema: { type: 'integer', minimum: 1, maximum: 200 },
description: 'How many of the latest events to return (default 50)',
},
],
responses: {
200: {
description: 'Latest events, newest first',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/Event' } },
},
},
},
400: {
description: 'limit is not an integer in [1, 200]',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
},
components: {
schemas: {
@@ -682,6 +716,17 @@ export const openApiSpec = {
},
required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'],
},
Event: {
type: 'object',
properties: {
id: { type: 'integer' },
turn: { type: 'integer', description: 'The save turn the action cost (new_game is 0)' },
type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] },
message: { type: 'string' },
createdAt: { type: 'string', description: 'ISO 8601 timestamp' },
},
required: ['id', 'turn', 'type', 'message', 'createdAt'],
},
UpgradeRequest: {
type: 'object',
properties: {