From 53b4bc39f50a6ca3053713bdf7a7ab559eff6a0f Mon Sep 17 00:00:00 2001 From: Samu Date: Thu, 3 Sep 2026 21:37:06 +0200 Subject: [PATCH] feat: loan tracking routes --- server/src/app.ts | 2 + server/src/routes/loanRoutes.ts | 64 +++++++++++++++++++++++++++ server/test/loans.test.ts | 78 +++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 server/src/routes/loanRoutes.ts create mode 100644 server/test/loans.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 6e840c3..154a05e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -15,6 +15,7 @@ import { registerLookupRoutes } from './routes/lookupRoutes.js' import { registerCollectionRoutes } from './routes/collectionRoutes.js' import { registerLibraryRoutes } from './routes/libraryRoutes.js' import { registerStreamRoutes } from './routes/streamRoutes.js' +import { registerLoanRoutes } from './routes/loanRoutes.js' declare module 'fastify' { interface FastifyInstance { @@ -54,6 +55,7 @@ export async function buildApp(opts: AppOptions): Promise { await registerCollectionRoutes(app) await registerLibraryRoutes(app) await registerStreamRoutes(app) + await registerLoanRoutes(app) // artwork cache (always available) await app.register(fastifyStatic, { diff --git a/server/src/routes/loanRoutes.ts b/server/src/routes/loanRoutes.ts new file mode 100644 index 0000000..a45390a --- /dev/null +++ b/server/src/routes/loanRoutes.ts @@ -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 { + 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 } + }) +} diff --git a/server/test/loans.test.ts b/server/test/loans.test.ts new file mode 100644 index 0000000..9d7df05 --- /dev/null +++ b/server/test/loans.test.ts @@ -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() + }) +})