fix: allow deleting equipment
- Add `DELETE /api/equipments/:id` endpoint. - Add `deleteEquipment` method to `EquipmentService` which also cleans up associated assets and generation references. - Implement delete action in `Equipments` page UI. - Pass `actions` prop to `VibeCard` to enable context menu.
This commit is contained in:
1 parent
b2f6869563
commit
45f7d0b2ef
5 files changed
+120
-1
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
+12
-1
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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: <Trash2 className="w-4 h-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user