feat: loan tracking routes
This commit is contained in:
@@ -15,6 +15,7 @@ import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
|||||||
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
||||||
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
import { registerLibraryRoutes } from './routes/libraryRoutes.js'
|
||||||
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
import { registerStreamRoutes } from './routes/streamRoutes.js'
|
||||||
|
import { registerLoanRoutes } from './routes/loanRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -54,6 +55,7 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerCollectionRoutes(app)
|
await registerCollectionRoutes(app)
|
||||||
await registerLibraryRoutes(app)
|
await registerLibraryRoutes(app)
|
||||||
await registerStreamRoutes(app)
|
await registerStreamRoutes(app)
|
||||||
|
await registerLoanRoutes(app)
|
||||||
|
|
||||||
// artwork cache (always available)
|
// artwork cache (always available)
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
|
|||||||
64
server/src/routes/loanRoutes.ts
Normal file
64
server/src/routes/loanRoutes.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { FastifyInstance } from 'fastify'
|
||||||
|
import { requireAuth } from './authRoutes.js'
|
||||||
|
|
||||||
|
interface LoanRow {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
item_id: number
|
||||||
|
borrower: string
|
||||||
|
lent_at: string
|
||||||
|
returned_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLoan(l: LoanRow) {
|
||||||
|
return { id: l.id, itemId: l.item_id, borrower: l.borrower, lentAt: l.lent_at, returnedAt: l.returned_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerLoanRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post('/api/collection/:id/loan', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
const item = db
|
||||||
|
.prepare('SELECT id FROM collection_items WHERE id = ? AND user_id = ?')
|
||||||
|
.get(id, userId)
|
||||||
|
if (!item) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
const { borrower } = (request.body ?? {}) as { borrower?: string }
|
||||||
|
if (typeof borrower !== 'string' || borrower.trim() === '') {
|
||||||
|
return reply.code(400).send({ error: 'invalid_input', detail: 'borrower is required' })
|
||||||
|
}
|
||||||
|
const active = db
|
||||||
|
.prepare('SELECT id FROM loans WHERE item_id = ? AND returned_at IS NULL')
|
||||||
|
.get(id)
|
||||||
|
if (active) return reply.code(409).send({ error: 'already_on_loan' })
|
||||||
|
const info = db
|
||||||
|
.prepare('INSERT INTO loans (user_id, item_id, borrower) VALUES (?, ?, ?)')
|
||||||
|
.run(userId, id, borrower.trim())
|
||||||
|
const loan = db.prepare('SELECT * FROM loans WHERE id = ?').get(info.lastInsertRowid) as LoanRow
|
||||||
|
return reply.code(200).send(toLoan(loan))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/loans', { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const active = db
|
||||||
|
.prepare('SELECT * FROM loans WHERE user_id = ? AND returned_at IS NULL ORDER BY lent_at DESC')
|
||||||
|
.all(userId) as LoanRow[]
|
||||||
|
const history = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM loans WHERE user_id = ? AND returned_at IS NOT NULL ORDER BY returned_at DESC LIMIT 50'
|
||||||
|
)
|
||||||
|
.all(userId) as LoanRow[]
|
||||||
|
return { active: active.map(toLoan), history: history.map(toLoan) }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/loans/:id/return', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
const info = db
|
||||||
|
.prepare("UPDATE loans SET returned_at = datetime('now') WHERE id = ? AND user_id = ? AND returned_at IS NULL")
|
||||||
|
.run(id, request.user!.id)
|
||||||
|
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
78
server/test/loans.test.ts
Normal file
78
server/test/loans.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildTestApp, setupAdmin, auth } from './helpers.js'
|
||||||
|
import { discogsReleaseFixture } from './fixtures.js'
|
||||||
|
|
||||||
|
const discogsStub = (async (input: any) =>
|
||||||
|
String(input).includes('/releases/1001')
|
||||||
|
? new Response(JSON.stringify(discogsReleaseFixture), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
: new Response('nope', { status: 404 })) as typeof fetch
|
||||||
|
|
||||||
|
async function appWithItem() {
|
||||||
|
const app = await buildTestApp(discogsStub)
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
await app.inject({ method: 'PUT', url: '/api/settings', ...auth(cookie), payload: { discogsToken: 't' } })
|
||||||
|
const added = await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
return { app, cookie, itemId: added.json().id as number }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loans', () => {
|
||||||
|
it('lend, list active, return', async () => {
|
||||||
|
const { app, cookie, itemId } = await appWithItem()
|
||||||
|
|
||||||
|
const lend = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Bob' },
|
||||||
|
})
|
||||||
|
expect(lend.statusCode).toBe(200)
|
||||||
|
const loan = lend.json()
|
||||||
|
expect(loan).toMatchObject({ itemId, borrower: 'Bob', returnedAt: null })
|
||||||
|
|
||||||
|
const dup = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Eve' },
|
||||||
|
})
|
||||||
|
expect(dup.statusCode).toBe(409)
|
||||||
|
expect(dup.json()).toEqual({ error: 'already_on_loan' })
|
||||||
|
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
|
||||||
|
expect(list.json().active).toHaveLength(1)
|
||||||
|
expect(list.json().history).toHaveLength(0)
|
||||||
|
|
||||||
|
const ret = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
|
||||||
|
expect(ret.statusCode).toBe(200)
|
||||||
|
const after = await app.inject({ method: 'GET', url: '/api/loans', ...auth(cookie) })
|
||||||
|
expect(after.json().active).toHaveLength(0)
|
||||||
|
expect(after.json().history).toHaveLength(1)
|
||||||
|
|
||||||
|
const again = await app.inject({ method: 'POST', url: `/api/loans/${loan.id}/return`, ...auth(cookie) })
|
||||||
|
expect(again.statusCode).toBe(404)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates borrower and ownership', async () => {
|
||||||
|
const { app, cookie, itemId } = await appWithItem()
|
||||||
|
const empty = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${itemId}/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: ' ' },
|
||||||
|
})
|
||||||
|
expect(empty.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/9999/loan`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { borrower: 'Bob' },
|
||||||
|
})
|
||||||
|
expect(missing.statusCode).toBe(404)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user