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:
@@ -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}`);
|
||||
|
||||
56
server/src/routes/weapons.ts
Normal file
56
server/src/routes/weapons.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
84
server/src/services/weapon-service.ts
Normal file
84
server/src/services/weapon-service.ts
Normal 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 };
|
||||
}
|
||||
34
server/src/validation/weapon-schema.ts
Normal file
34
server/src/validation/weapon-schema.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user