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 <noreply@anthropic.com>
This commit is contained in:
Bas van Rossem
2026-02-19 16:18:21 +01:00
parent 5f179229d6
commit 525a1a4a95
4 changed files with 176 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import { PORT } from './config.js';
import { runMigrations } from './db/migrate.js'; import { runMigrations } from './db/migrate.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { shipRoutes } from './routes/ships.js'; import { shipRoutes } from './routes/ships.js';
import { weaponRoutes } from './routes/weapons.js';
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -14,6 +15,7 @@ const start = async () => {
await app.register(cors, { origin: true }); await app.register(cors, { origin: true });
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(shipRoutes); await app.register(shipRoutes);
await app.register(weaponRoutes);
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}`);

View File

@@ -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 };
},
);
}

View File

@@ -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<string, unknown>)[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<string, unknown>)[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 };
}

View File

@@ -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<typeof createWeaponSchema>;
export type UpdateWeaponInput = z.infer<typeof updateWeaponSchema>;