fix: loan error surfacing, non-admin export, guarded atomic migration, expanded player controls
This commit is contained in:
@@ -95,18 +95,24 @@ function setSchemaVersion(db: DB, version: number): void {
|
|||||||
export function migrateUpgrades(db: DB): void {
|
export function migrateUpgrades(db: DB): void {
|
||||||
const version = getSchemaVersion(db)
|
const version = getSchemaVersion(db)
|
||||||
if (version < 2) {
|
if (version < 2) {
|
||||||
db.exec(`
|
const hasColumn = (db.prepare('PRAGMA table_info(digital_albums)').all() as { name: string }[]).some(
|
||||||
ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT;
|
(c) => c.name === 'last_played_at'
|
||||||
CREATE TABLE IF NOT EXISTS loans (
|
)
|
||||||
|
const apply = db.transaction(() => {
|
||||||
|
if (!hasColumn) {
|
||||||
|
db.exec('ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT')
|
||||||
|
}
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS loans (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
|
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
|
||||||
borrower TEXT NOT NULL,
|
borrower TEXT NOT NULL,
|
||||||
lent_at TEXT NOT NULL DEFAULT (datetime('now')),
|
lent_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
returned_at TEXT
|
returned_at TEXT
|
||||||
);
|
);`)
|
||||||
`)
|
|
||||||
setSchemaVersion(db, 2)
|
setSchemaVersion(db, 2)
|
||||||
|
})
|
||||||
|
apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api.js'
|
import { api, ApiError } from '../api.js'
|
||||||
import { usePlayer } from '../player/PlayerContext.js'
|
import { usePlayer } from '../player/PlayerContext.js'
|
||||||
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
||||||
import Cover from '../components/Cover.js'
|
import Cover from '../components/Cover.js'
|
||||||
@@ -249,7 +249,18 @@ export default function ItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void api.returnLoan(item.loan!.id).then(refetch)}
|
onClick={() =>
|
||||||
|
void api
|
||||||
|
.returnLoan(item.loan!.id)
|
||||||
|
.then(refetch)
|
||||||
|
.catch((err) =>
|
||||||
|
setMutationError(
|
||||||
|
err instanceof ApiError && err.code === 'already_on_loan'
|
||||||
|
? 'That record is already out to someone.'
|
||||||
|
: "That didn't work — check your connection and try again."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||||
>
|
>
|
||||||
Mark returned
|
Mark returned
|
||||||
@@ -260,7 +271,16 @@ export default function ItemPage() {
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
|
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
|
||||||
void api.lendItem(item.id, borrower).then(refetch)
|
void api
|
||||||
|
.lendItem(item.id, borrower)
|
||||||
|
.then(refetch)
|
||||||
|
.catch((err) =>
|
||||||
|
setMutationError(
|
||||||
|
err instanceof ApiError && err.code === 'already_on_loan'
|
||||||
|
? 'That record is already out to someone.'
|
||||||
|
: "That didn't work — check your connection and try again."
|
||||||
|
)
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -239,7 +239,6 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{user?.isAdmin && (
|
|
||||||
<Section title="Data">
|
<Section title="Data">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
@@ -249,6 +248,7 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
Export JSON
|
Export JSON
|
||||||
</a>
|
</a>
|
||||||
|
{user?.isAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -263,8 +263,9 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
Back up now
|
Back up now
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{backups && backups.length > 0 && (
|
{user?.isAdmin && backups && backups.length > 0 && (
|
||||||
<ul className="space-y-1 text-xs text-neutral-400">
|
<ul className="space-y-1 text-xs text-neutral-400">
|
||||||
{backups.map((b) => (
|
{backups.map((b) => (
|
||||||
<li key={b.file} className="flex justify-between gap-2">
|
<li key={b.file} className="flex justify-between gap-2">
|
||||||
@@ -277,7 +278,6 @@ export default function SettingsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
|
|
||||||
{user?.isAdmin && (
|
{user?.isAdmin && (
|
||||||
<Section title="Users">
|
<Section title="Users">
|
||||||
|
|||||||
@@ -17,7 +17,21 @@ export default function MiniBar() {
|
|||||||
<p className="truncate font-medium">{state.album.title}</p>
|
<p className="truncate font-medium">{state.album.title}</p>
|
||||||
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
|
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={state.playing ? 'pause' : 'play'}
|
||||||
|
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
|
||||||
|
>
|
||||||
|
{state.playing ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
|
<button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
|
||||||
▾
|
▾
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
import { api } from '../src/api.js'
|
import { api, ApiError } from '../src/api.js'
|
||||||
|
|
||||||
const item: ItemDetail = {
|
const item: ItemDetail = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -227,4 +227,12 @@ describe('ItemPage', () => {
|
|||||||
await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
|
await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
|
||||||
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
|
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lend failure surfaces the inline message', async () => {
|
||||||
|
vi.mocked(api.lendItem).mockRejectedValue(new ApiError(409, 'already_on_loan'))
|
||||||
|
renderItem()
|
||||||
|
await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^lend$/i }))
|
||||||
|
await waitFor(() => expect(screen.getByText(/already out to someone/i)).toBeTruthy())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,4 +69,13 @@ describe('MiniBar', () => {
|
|||||||
await userEvent.click(screen.getByRole('button', { name: /close player/i }))
|
await userEvent.click(screen.getByRole('button', { name: /close player/i }))
|
||||||
await waitFor(() => expect(screen.queryByText('Motion')).toBeNull())
|
await waitFor(() => expect(screen.queryByText('Motion')).toBeNull())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('expanded view has prev/next/pause controls', async () => {
|
||||||
|
renderBar()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'load' }))
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: /expand/i }))
|
||||||
|
expect(screen.getByRole('button', { name: /previous track/i })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: /next track/i })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -201,4 +201,11 @@ describe('SettingsPage', () => {
|
|||||||
await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
|
await userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
|
||||||
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
|
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('non-admin sees the Export link but not backups', async () => {
|
||||||
|
stubAuthFetch({ id: 2, username: 'bob', isAdmin: false })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('link', { name: /export json/i })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: /back up now/i })).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user