diff --git a/server/src/index.ts b/server/src/index.ts index 4c2df9a..b08efe2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) { diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..9acaf7a --- /dev/null +++ b/server/src/routes/health.ts @@ -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() }; + }); +} diff --git a/server/src/routes/ships.ts b/server/src/routes/ships.ts new file mode 100644 index 0000000..b5f2be2 --- /dev/null +++ b/server/src/routes/ships.ts @@ -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 }; + }); +} diff --git a/server/src/services/ship-service.ts b/server/src/services/ship-service.ts new file mode 100644 index 0000000..83f0850 --- /dev/null +++ b/server/src/services/ship-service.ts @@ -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[] { + return db + .prepare('SELECT id, name, updated_at FROM ships ORDER BY updated_at DESC') + .all() as Pick[]; +} + +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)[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)[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; +} diff --git a/server/src/validation/ship-schema.ts b/server/src/validation/ship-schema.ts new file mode 100644 index 0000000..26f4c1b --- /dev/null +++ b/server/src/validation/ship-schema.ts @@ -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; +export type UpdateShipInput = z.infer;