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