feat(server): implement ship REST endpoints with Zod validation

Adds GET/POST/PATCH/DELETE /api/ships with constraint validation (current <= max),
enum checks for maneuver_class, and cascade delete for weapons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bas van Rossem
2026-02-19 16:17:16 +01:00
parent 4b4d105009
commit 5f179229d6
5 changed files with 236 additions and 4 deletions

View File

@@ -1,17 +1,20 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { PORT } from './config.js';
import { runMigrations } from './db/migrate.js';
import { healthRoutes } from './routes/health.js';
import { shipRoutes } from './routes/ships.js';
const app = Fastify({ logger: true });
app.get('/', async () => {
return { name: 'Spelljammer Ship Tracker API', status: 'ok' };
});
const start = async () => {
try {
runMigrations();
await app.register(cors, { origin: true });
await app.register(healthRoutes);
await app.register(shipRoutes);
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`Server running on port ${PORT}`);
} catch (err) {

View File

@@ -0,0 +1,7 @@
import type { FastifyInstance } from 'fastify';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health', async () => {
return { status: 'ok', timestamp: Date.now() };
});
}

View File

@@ -0,0 +1,56 @@
import type { FastifyInstance } from 'fastify';
import { createShipSchema, updateShipSchema } from '../validation/ship-schema.js';
import * as shipService from '../services/ship-service.js';
export async function shipRoutes(app: FastifyInstance) {
// List all ships
app.get('/api/ships', async () => {
return shipService.listShips();
});
// Get a single ship with its weapons
app.get<{ Params: { id: string } }>('/api/ships/:id', async (request, reply) => {
const result = shipService.getShip(request.params.id);
if (!result) {
return reply.code(404).send({ error: 'Ship not found' });
}
return result;
});
// Create a new ship
app.post('/api/ships', async (request, reply) => {
const parsed = createShipSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'Validation failed', details: parsed.error.issues });
}
const ship = shipService.createShip(parsed.data);
return reply.code(201).send(ship);
});
// Update a ship (partial)
app.patch<{ Params: { id: string } }>('/api/ships/:id', async (request, reply) => {
const parsed = updateShipSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'Validation failed', details: parsed.error.issues });
}
try {
const ship = shipService.updateShip(request.params.id, parsed.data);
if (!ship) {
return reply.code(404).send({ error: 'Ship not found' });
}
return ship;
} catch (err: any) {
return reply.code(400).send({ error: err.message });
}
});
// Delete a ship
app.delete<{ Params: { id: string } }>('/api/ships/:id', async (request, reply) => {
const deleted = shipService.deleteShip(request.params.id);
if (!deleted) {
return reply.code(404).send({ error: 'Ship not found' });
}
return { success: true };
});
}

View File

@@ -0,0 +1,117 @@
import { v4 as uuid } from 'uuid';
import db from '../db/connection.js';
import type { CreateShipInput, UpdateShipInput } from '../validation/ship-schema.js';
export interface Ship {
id: string;
name: string;
hull_current: number;
hull_max: number;
armor_current: number;
armor_max: number;
ac: number;
con_save: number | null;
speed: number | null;
maneuver_class: string | null;
size_category: string | null;
notes: string | null;
updated_at: number;
}
export interface Weapon {
id: string;
ship_id: string;
name: string;
type: string | null;
attack_mod: number | null;
damage: string | null;
range: string | null;
ammo_current: number | null;
ammo_max: number | null;
status: string | null;
notes: string | null;
sort_order: number;
updated_at: number;
}
export function listShips(): Pick<Ship, 'id' | 'name' | 'updated_at'>[] {
return db
.prepare('SELECT id, name, updated_at FROM ships ORDER BY updated_at DESC')
.all() as Pick<Ship, 'id' | 'name' | 'updated_at'>[];
}
export function getShip(id: string): { ship: Ship; weapons: Weapon[] } | null {
const ship = db.prepare('SELECT * FROM ships WHERE id = ?').get(id) as Ship | undefined;
if (!ship) return null;
const weapons = db
.prepare('SELECT * FROM weapons WHERE ship_id = ? ORDER BY sort_order ASC, name ASC')
.all(id) as Weapon[];
return { ship, weapons };
}
export function createShip(input: CreateShipInput): Ship {
const id = uuid();
const now = Date.now();
db.prepare(`
INSERT INTO ships (id, name, hull_current, hull_max, armor_current, armor_max, ac, con_save, speed, maneuver_class, size_category, notes, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.name,
input.hull_current,
input.hull_max,
input.armor_current,
input.armor_max,
input.ac,
input.con_save,
input.speed,
input.maneuver_class,
input.size_category,
input.notes,
now,
);
return db.prepare('SELECT * FROM ships WHERE id = ?').get(id) as Ship;
}
export function updateShip(id: string, patch: UpdateShipInput): Ship | null {
const existing = db.prepare('SELECT * FROM ships WHERE id = ?').get(id) as Ship | undefined;
if (!existing) return null;
const fields = Object.keys(patch).filter(
(k) => (patch as Record<string, unknown>)[k] !== undefined,
);
if (fields.length === 0) return existing;
// Validate current <= max against existing values when only one side is patched
const newHullCurrent = patch.hull_current ?? existing.hull_current;
const newHullMax = patch.hull_max ?? existing.hull_max;
const newArmorCurrent = patch.armor_current ?? existing.armor_current;
const newArmorMax = patch.armor_max ?? existing.armor_max;
if (newHullCurrent > newHullMax) {
throw new Error('hull_current cannot exceed hull_max');
}
if (newArmorCurrent > newArmorMax) {
throw new Error('armor_current cannot exceed armor_max');
}
const sets = fields.map((f) => `${f} = ?`);
sets.push('updated_at = ?');
const values = fields.map((f) => (patch as Record<string, unknown>)[f]);
values.push(Date.now());
values.push(id);
db.prepare(`UPDATE ships SET ${sets.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM ships WHERE id = ?').get(id) as Ship;
}
export function deleteShip(id: string): boolean {
const result = db.prepare('DELETE FROM ships WHERE id = ?').run(id);
return result.changes > 0;
}

View File

@@ -0,0 +1,49 @@
import { z } from 'zod';
const maneuverClasses = ['A', 'B', 'C', 'D', 'E', 'F', 'S'] as const;
export const createShipSchema = z.object({
name: z.string().min(1).max(100).default('Unnamed Ship'),
hull_current: z.number().int().min(0).default(0),
hull_max: z.number().int().min(0).default(0),
armor_current: z.number().int().min(0).default(0),
armor_max: z.number().int().min(0).default(0),
ac: z.number().int().min(0).default(10),
con_save: z.number().int().nullable().default(null),
speed: z.number().int().min(0).nullable().default(null),
maneuver_class: z.enum(maneuverClasses).nullable().default(null),
size_category: z.string().max(50).nullable().default(null),
notes: z.string().nullable().default(null),
});
export const updateShipSchema = z
.object({
name: z.string().min(1).max(100),
hull_current: z.number().int().min(0),
hull_max: z.number().int().min(0),
armor_current: z.number().int().min(0),
armor_max: z.number().int().min(0),
ac: z.number().int().min(0),
con_save: z.number().int().nullable(),
speed: z.number().int().min(0).nullable(),
maneuver_class: z.enum(maneuverClasses).nullable(),
size_category: z.string().max(50).nullable(),
notes: z.string().nullable(),
})
.partial()
.refine(
(data) => {
// Ensure current <= max when both are provided
if (data.hull_current !== undefined && data.hull_max !== undefined) {
return data.hull_current <= data.hull_max;
}
if (data.armor_current !== undefined && data.armor_max !== undefined) {
return data.armor_current <= data.armor_max;
}
return true;
},
{ message: 'Current values must not exceed max values' },
);
export type CreateShipInput = z.infer<typeof createShipSchema>;
export type UpdateShipInput = z.infer<typeof updateShipSchema>;