diff --git a/src/api/equipment.test.ts b/src/api/equipment.test.ts index cf1a0d3..bcb6e44 100644 --- a/src/api/equipment.test.ts +++ b/src/api/equipment.test.ts @@ -4,6 +4,7 @@ import equipment from './equipment' const mockEquipmentService = { listEquipments: mock(), updateEquipment: mock(), + deleteEquipment: mock(), } mock.module('../service/equipment', () => ({ @@ -14,6 +15,7 @@ describe('Equipment API', () => { afterEach(() => { mockEquipmentService.listEquipments.mockClear() mockEquipmentService.updateEquipment.mockClear() + mockEquipmentService.deleteEquipment.mockClear() }) it('GET / should list equipments with filters', async () => { @@ -46,4 +48,16 @@ describe('Equipment API', () => { name: 'Updated', }) }) + + it('DELETE /:id should delete equipment', async () => { + mockEquipmentService.deleteEquipment.mockResolvedValue({ id: '1' }) + + const req = new Request('http://localhost/1', { + method: 'DELETE', + }) + + const res = await equipment.fetch(req) + expect(res.status).toBe(200) + expect(mockEquipmentService.deleteEquipment).toHaveBeenCalledWith('1') + }) }) diff --git a/src/api/equipment.ts b/src/api/equipment.ts index 4c7cbc8..4f5aae2 100644 --- a/src/api/equipment.ts +++ b/src/api/equipment.ts @@ -42,4 +42,15 @@ const routeWithPatch = route.patch('/:id', zValidator('json', patchSchema), asyn } }) -export default routeWithPatch +const routeWithDelete = routeWithPatch.delete('/:id', async (c) => { + const { id } = c.req.param() + try { + await equipmentService.deleteEquipment(id) + return c.json({ success: true }) + } catch (e) { + console.error('Failed to delete equipment', e) + return c.json({ error: 'Failed to delete equipment' }, 500) + } +}) + +export default routeWithDelete diff --git a/src/service/equipment.test.ts b/src/service/equipment.test.ts index 5767b68..6dcec77 100644 --- a/src/service/equipment.test.ts +++ b/src/service/equipment.test.ts @@ -5,18 +5,35 @@ const mockPrisma = { findMany: mock(), count: mock(), update: mock(), + delete: mock(), + findUnique: mock(), }, + generationEquipment: { + deleteMany: mock(), + }, +} + +const mockAssetService = { + deleteAsset: mock(), } mock.module('../db', () => ({ prisma: mockPrisma, })) +mock.module('./asset', () => ({ + assetService: mockAssetService, +})) + describe('EquipmentService', () => { afterEach(() => { mockPrisma.equipment.findMany.mockClear() mockPrisma.equipment.count.mockClear() mockPrisma.equipment.update.mockClear() + mockPrisma.equipment.delete.mockClear() + mockPrisma.equipment.findUnique.mockClear() + mockPrisma.generationEquipment.deleteMany.mockClear() + mockAssetService.deleteAsset.mockClear() }) it('listEquipments should filter by category', async () => { @@ -60,4 +77,23 @@ describe('EquipmentService', () => { }), ) }) + + it('deleteEquipment should delete equipment and associated asset', async () => { + // @ts-ignore + const { equipmentService } = await import(`./equipment?v=${Date.now()}`) + mockPrisma.equipment.findUnique.mockResolvedValue({ + id: '1', + imageId: 'asset-1', + }) + mockPrisma.generationEquipment.deleteMany.mockResolvedValue({ count: 1 }) + mockPrisma.equipment.delete.mockResolvedValue({ id: '1' }) + mockAssetService.deleteAsset.mockResolvedValue(undefined) + + await equipmentService.deleteEquipment('1') + + expect(mockPrisma.equipment.findUnique).toHaveBeenCalledWith({ where: { id: '1' } }) + expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({ where: { equipmentId: '1' } }) + expect(mockPrisma.equipment.delete).toHaveBeenCalledWith({ where: { id: '1' } }) + expect(mockAssetService.deleteAsset).toHaveBeenCalledWith('asset-1') + }) }) diff --git a/src/service/equipment.ts b/src/service/equipment.ts index 33aa457..8182fee 100644 --- a/src/service/equipment.ts +++ b/src/service/equipment.ts @@ -1,6 +1,7 @@ import { prisma } from '../db' import { Prisma } from '../generated/prisma/client' import { ulid } from 'ulidx' +import { assetService } from './asset' export class EquipmentService { /** @@ -87,6 +88,39 @@ export class EquipmentService { }, }) } + + /** + * Deletes an equipment item and its associated asset. + */ + async deleteEquipment(id: string) { + const equipment = await prisma.equipment.findUnique({ + where: { id }, + }) + + if (!equipment) return + + // Delete related GenerationEquipment records manually since there is no cascade + await prisma.generationEquipment.deleteMany({ + where: { equipmentId: id }, + }) + + // Delete equipment + await prisma.equipment.delete({ + where: { id }, + }) + + // Delete associated asset + // Note: We assume the asset is exclusive to this equipment, which is true for extracted assets. + if (equipment.imageId) { + try { + await assetService.deleteAsset(equipment.imageId) + } catch (e) { + console.warn(`Failed to delete asset ${equipment.imageId} for equipment ${id}`, e) + } + } + + return equipment + } } export const equipmentService = new EquipmentService() diff --git a/src/ui/pages/Equipments.tsx b/src/ui/pages/Equipments.tsx index 2899528..636d346 100644 --- a/src/ui/pages/Equipments.tsx +++ b/src/ui/pages/Equipments.tsx @@ -91,6 +91,22 @@ export default function Equipments() { resetEquipment() }, [selectedCategory, resetEquipment]) + const handleDeleteEquipment = async (id: string) => { + if (!confirm('Are you sure you want to delete this equipment? This cannot be undone.')) return + try { + const res = await client.equipments[':id'].$delete({ param: { id } }) + if (res.ok) { + toast.success('Equipment deleted') + resetEquipment() + } else { + toast.error('Failed to delete equipment') + } + } catch (e) { + console.error(e) + toast.error('Failed to delete equipment') + } + } + const handleDeleteOutfit = async (id: string) => { if (!confirm('Are you sure you want to delete this outfit?')) return try { @@ -198,6 +214,14 @@ export default function Equipments() { setSelectedEquipment(item) setEquipmentDetailsOpen(true) }} + actions={[ + { + name: 'Delete', + onClick: () => handleDeleteEquipment(item.id), + variant: 'destructive', + icon: , + }, + ]} /> ))}