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:
@@ -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) {
|
||||
|
||||
7
server/src/routes/health.ts
Normal file
7
server/src/routes/health.ts
Normal 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() };
|
||||
});
|
||||
}
|
||||
56
server/src/routes/ships.ts
Normal file
56
server/src/routes/ships.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
117
server/src/services/ship-service.ts
Normal file
117
server/src/services/ship-service.ts
Normal 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;
|
||||
}
|
||||
49
server/src/validation/ship-schema.ts
Normal file
49
server/src/validation/ship-schema.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user