From 525a1a4a9552855081781ea6f8efc429b4b30347 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 16:18:21 +0100 Subject: [PATCH] feat(server): implement weapon REST endpoints with validation Adds POST/PATCH/DELETE for weapons with ammo constraint validation, status enum checks, and ship existence verification on create. Co-Authored-By: Claude Opus 4.6 --- server/src/index.ts | 2 + server/src/routes/weapons.ts | 56 +++++++++++++++++ server/src/services/weapon-service.ts | 84 ++++++++++++++++++++++++++ server/src/validation/weapon-schema.ts | 34 +++++++++++ 4 files changed, 176 insertions(+) create mode 100644 server/src/routes/weapons.ts create mode 100644 server/src/services/weapon-service.ts create mode 100644 server/src/validation/weapon-schema.ts diff --git a/server/src/index.ts b/server/src/index.ts index b08efe2..06444a1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import { PORT } from './config.js'; import { runMigrations } from './db/migrate.js'; import { healthRoutes } from './routes/health.js'; import { shipRoutes } from './routes/ships.js'; +import { weaponRoutes } from './routes/weapons.js'; const app = Fastify({ logger: true }); @@ -14,6 +15,7 @@ const start = async () => { await app.register(cors, { origin: true }); await app.register(healthRoutes); await app.register(shipRoutes); + await app.register(weaponRoutes); await app.listen({ port: PORT, host: '0.0.0.0' }); console.log(`Server running on port ${PORT}`); diff --git a/server/src/routes/weapons.ts b/server/src/routes/weapons.ts new file mode 100644 index 0000000..cb2844b --- /dev/null +++ b/server/src/routes/weapons.ts @@ -0,0 +1,56 @@ +import type { FastifyInstance } from 'fastify'; +import { createWeaponSchema, updateWeaponSchema } from '../validation/weapon-schema.js'; +import * as weaponService from '../services/weapon-service.js'; + +export async function weaponRoutes(app: FastifyInstance) { + // Add weapon to a ship + app.post<{ Params: { id: string } }>('/api/ships/:id/weapons', async (request, reply) => { + const parsed = createWeaponSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'Validation failed', details: parsed.error.issues }); + } + + try { + const weapon = weaponService.createWeapon(request.params.id, parsed.data); + return reply.code(201).send(weapon); + } catch (err: any) { + if (err.message === 'Ship not found') { + return reply.code(404).send({ error: 'Ship not found' }); + } + throw err; + } + }); + + // Update a weapon + app.patch<{ Params: { weaponId: string } }>( + '/api/weapons/:weaponId', + async (request, reply) => { + const parsed = updateWeaponSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: 'Validation failed', details: parsed.error.issues }); + } + + try { + const weapon = weaponService.updateWeapon(request.params.weaponId, parsed.data); + if (!weapon) { + return reply.code(404).send({ error: 'Weapon not found' }); + } + return weapon; + } catch (err: any) { + return reply.code(400).send({ error: err.message }); + } + }, + ); + + // Delete a weapon + app.delete<{ Params: { weaponId: string } }>( + '/api/weapons/:weaponId', + async (request, reply) => { + const result = weaponService.deleteWeapon(request.params.weaponId); + if (!result) { + return reply.code(404).send({ error: 'Weapon not found' }); + } + return { success: true, shipId: result.shipId }; + }, + ); +} diff --git a/server/src/services/weapon-service.ts b/server/src/services/weapon-service.ts new file mode 100644 index 0000000..e4fc42d --- /dev/null +++ b/server/src/services/weapon-service.ts @@ -0,0 +1,84 @@ +import { v4 as uuid } from 'uuid'; +import db from '../db/connection.js'; +import type { Weapon } from './ship-service.js'; +import type { CreateWeaponInput, UpdateWeaponInput } from '../validation/weapon-schema.js'; + +export function getWeaponsForShip(shipId: string): Weapon[] { + return db + .prepare('SELECT * FROM weapons WHERE ship_id = ? ORDER BY sort_order ASC, name ASC') + .all(shipId) as Weapon[]; +} + +export function getWeapon(weaponId: string): Weapon | null { + return (db.prepare('SELECT * FROM weapons WHERE id = ?').get(weaponId) as Weapon) ?? null; +} + +export function createWeapon(shipId: string, input: CreateWeaponInput): Weapon { + // Verify ship exists + const ship = db.prepare('SELECT id FROM ships WHERE id = ?').get(shipId); + if (!ship) throw new Error('Ship not found'); + + const id = uuid(); + const now = Date.now(); + + db.prepare(` + INSERT INTO weapons (id, ship_id, name, type, attack_mod, damage, range, ammo_current, ammo_max, status, notes, sort_order, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + shipId, + input.name, + input.type, + input.attack_mod, + input.damage, + input.range, + input.ammo_current, + input.ammo_max, + input.status, + input.notes, + input.sort_order, + now, + ); + + return db.prepare('SELECT * FROM weapons WHERE id = ?').get(id) as Weapon; +} + +export function updateWeapon(weaponId: string, patch: UpdateWeaponInput): Weapon | null { + const existing = db.prepare('SELECT * FROM weapons WHERE id = ?').get(weaponId) as + | Weapon + | undefined; + if (!existing) return null; + + const fields = Object.keys(patch).filter( + (k) => (patch as Record)[k] !== undefined, + ); + if (fields.length === 0) return existing; + + // Validate ammo_current <= ammo_max when both present + const newAmmoCurrent = patch.ammo_current ?? existing.ammo_current; + const newAmmoMax = patch.ammo_max ?? existing.ammo_max; + if (newAmmoCurrent !== null && newAmmoMax !== null && newAmmoCurrent > newAmmoMax) { + throw new Error('ammo_current cannot exceed ammo_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(weaponId); + + db.prepare(`UPDATE weapons SET ${sets.join(', ')} WHERE id = ?`).run(...values); + + return db.prepare('SELECT * FROM weapons WHERE id = ?').get(weaponId) as Weapon; +} + +export function deleteWeapon(weaponId: string): { shipId: string } | null { + const weapon = db.prepare('SELECT ship_id FROM weapons WHERE id = ?').get(weaponId) as + | { ship_id: string } + | undefined; + if (!weapon) return null; + + db.prepare('DELETE FROM weapons WHERE id = ?').run(weaponId); + return { shipId: weapon.ship_id }; +} diff --git a/server/src/validation/weapon-schema.ts b/server/src/validation/weapon-schema.ts new file mode 100644 index 0000000..5ff4540 --- /dev/null +++ b/server/src/validation/weapon-schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +const weaponStatuses = ['ok', 'damaged', 'disabled'] as const; + +export const createWeaponSchema = z.object({ + name: z.string().min(1).max(100).default('Unnamed Weapon'), + type: z.string().max(50).nullable().default(null), + attack_mod: z.number().int().nullable().default(null), + damage: z.string().max(50).nullable().default(null), + range: z.string().max(50).nullable().default(null), + ammo_current: z.number().int().min(0).nullable().default(null), + ammo_max: z.number().int().min(0).nullable().default(null), + status: z.enum(weaponStatuses).nullable().default('ok'), + notes: z.string().nullable().default(null), + sort_order: z.number().int().min(0).default(0), +}); + +export const updateWeaponSchema = z + .object({ + name: z.string().min(1).max(100), + type: z.string().max(50).nullable(), + attack_mod: z.number().int().nullable(), + damage: z.string().max(50).nullable(), + range: z.string().max(50).nullable(), + ammo_current: z.number().int().min(0).nullable(), + ammo_max: z.number().int().min(0).nullable(), + status: z.enum(weaponStatuses).nullable(), + notes: z.string().nullable(), + sort_order: z.number().int().min(0), + }) + .partial(); + +export type CreateWeaponInput = z.infer; +export type UpdateWeaponInput = z.infer;