Skip to content

feat: Add privacy-first lending history and invite flow#24

Open
niklhut wants to merge 9 commits into
mainfrom
feat/lending
Open

feat: Add privacy-first lending history and invite flow#24
niklhut wants to merge 9 commits into
mainfrom
feat/lending

Conversation

@niklhut
Copy link
Copy Markdown
Owner

@niklhut niklhut commented May 20, 2026

Summary

  • Add a full lending model with immutable loan snapshots, owner/borrower history, and archived owner copies.
  • Introduce /library/loans with a dedicated switcher for books loaned out vs books lent to you, plus public invite pages and accept flows.
  • Update book detail UI to show active loan state, return actions, and a cleaner lending modal that copies the share link.
  • Preserve borrower history after return or owner removal, while blocking self-redeem and keeping invite data public-safe.

Testing

  • Added and updated unit coverage for lending creation, invite preview/acceptance, and active-loan removal confirmation.
  • Verified the lending routes, query-state persistence, and detail-page UI locally.
  • pnpm lint, pnpm typecheck, and pnpm test:unit passed.

Summary by CodeRabbit

  • New Features

    • Complete book lending flow: record loans, generate/share invite links, accept invites, and return/cancel loans.
    • New "Loans" page with "Loaned out" and "Lent to you" views and per-loan actions.
    • New modal to record a loan and per-book "Lent out" badge.
  • Improvements

    • Preserve redirect across login/registration.
    • Better book removal: soft-delete and explicit confirmation when a book has an active loan.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements a full lending feature: DB schema and types, repositories/services with Effect layers, API endpoints for loans and invites, UI components/pages (lending modal, loans dashboard, invite acceptance), soft-delete removal with confirm flow, and tests.

Changes

Book Lending Feature Implementation

Layer / File(s) Summary
Shared types and validation schemas for lending
shared/types/book.ts, shared/utils/schemas.ts
Adds LoanStatus, ActiveLoanSummary, OwnerLoan, BorrowedBook, InvitePreview types; createLoanSchema validates borrower name/email and due date (min = tomorrow); removeBookSchema adds confirmActiveLoan flag.
Database schema for loans and soft-delete tracking
server/db/schema/domain.ts
userBooks gains nullable removedAt; loans table redesigned with ownerUserId/userBookId, borrower identity (nullable borrowerUserId plus display name/email), status enum, lifecycle timestamps, snapshot book metadata, accept token/hash, updatedAt, and partial unique indexes.
Book repository: active-loan hydration & soft-delete removal
server/repositories/book.repository.ts
Adds ActiveLoanRemovalError; hydrates activeLoan summaries via hydrateActiveLoansByUserBookIds; excludes soft-deleted rows across queries; removeFromLibrary soft-deletes via removedAt, accepts confirmActiveLoan option, and fails early when active loan exists unless confirmed.
Book service updates for active loans and removal
server/services/book.service.ts
UserBookViewModel and toLibraryBook include activeLoan; removeBookFromLibrary signature accepts optional { confirmActiveLoan?: boolean } and forwards option through service/helper.
Lending repository data-access layer
server/repositories/lending.repository.ts
Effect-based repository implementing createLoan, getActiveLoanForBook, returnLoan, cancelLoan, listOwnerLoans, listBorrowedBooks, getInvitePreviewByHash, acceptInviteByHash; exports domain errors and convenience wrappers.
Lending service and orchestration
server/services/lending.service.ts
LendingServiceInterface and LendingServiceLive: generates acceptance tokens, SHA-256-hashes tokens, constructs inviteUrl on creation, and orchestrates repository calls for create/return/cancel/list/preview/accept; exports helpers.
Effect wiring and error mapping
server/utils/effect.ts
Merges LendingRepositoryLive/LendingServiceLive into live layers, extends MainServices, maps lending error types to HTTP status codes, and formats user-facing messages for ActiveLoanRemovalError and ActiveLoanExistsError.
API endpoints: loans CRUD & listing
server/api/loans/index.get.ts, server/api/loans/[id]/return.post.ts, server/api/loans/[id]/cancel.post.ts, server/api/books/[id]/loans/index.post.ts, server/api/borrowed/index.get.ts
Adds GET /api/loans, GET /api/borrowed, POST /api/books/{id}/loans (create loan with validation), POST /api/loans/{id}/return, and POST /api/loans/{id}/cancel; all require auth and validate required params.
API endpoints: invite preview, accept, and cover
server/api/invite/[token]/index.get.ts, server/api/invite/[token]/accept.post.ts, server/api/invite/[token]/cover.get.ts
GET preview returns invite with optional session context; POST accept atomically claims invite; GET cover serves blob with Content-Type and Cache-Control: private, max-age=3600; handlers validate token and map missing resources to 400/404.
DELETE book endpoint with active-loan confirmation
server/api/books/[id]/index.delete.ts
Parses confirmActiveLoan query param and forwards { confirmActiveLoan } to removeBookFromLibrary, enabling conditional removal flow.
BookCard component lending indicator
app/components/BookCard.vue
Adds optional activeLoan prop; conditionally renders a "Lent out" badge overlay in both non-selectable and selectable header variants.
BookLendingModal component for loan creation
app/components/BookLendingModal.vue
Modal for recording loans: borrower name (required), optional email, due date (min = tomorrow); validates input, POSTs to create loan, builds inviteUrl, attempts auto-copy, emits saved with loan + inviteUrl, and shows toasts; inputs disabled after invite generation.
Library detail page lending & removal flows
app/pages/library/[id].vue
Adds lending-related reactive state, BookLendingModal wiring, return flow (returnActiveLoan) with loading guard and toasts, "Loan" UI section when book.activeLoan exists, "Record loan" action when no active loan, and enhanced removal flow opening confirm dialog on 409 and passing confirmActiveLoan when confirmed.
Loan display components: loaned-out and borrowed sections
app/components/LoanedOutLoansSection.vue, app/components/BorrowedLoansSection.vue
LoanedOutLoansSection renders active and history loans with borrower labels, dates, status badges, and emits returnLoan; BorrowedLoansSection renders borrowed books current/history views with lender/date formatting.
Loans overview page with owner/borrowed toggle
app/pages/library/loans.vue
Two-button toggle initialized from route.query.view; fetches /api/loans and /api/borrowed; computes isPending; handles returnLoan() with concurrency guard, refresh, and toasts; delegates rendering to the two section components.
Public invitation acceptance page
app/pages/i/[token].vue
Public invite page (auth disabled): fetches invite preview, renders cover/title/author/owner/due date, shows alerts for invite status, conditionally shows accept or sign-in/register buttons, and posts acceptance with duplicate-submit protection and navigation on success.
Library index & navigation updates
app/pages/library/index.vue, app/components/AppHeader.vue
Library index adds "Loans" button (to /library/loans) when books exist and passes :active-loan to BookCard; AppHeader expands logged-in nav links to include Library and Loans alongside Sign Out.
Auth redirect preservation in login/register
app/pages/(auth)/login.vue, app/pages/(auth)/register.vue
Login preserves redirect query when linking to register; register computes validated redirectPath from route.query.redirect (fallback /library), uses it for post-signup navigation, and preserves redirect in "Sign in" link.
Test helpers and endpoint tests
test/unit/server/api/_helpers/api-route.ts, test/unit/server/api/books/[id]/index.delete.test.ts, test/unit/server/api/books/[id]/loans/index.post.test.ts, test/unit/server/api/invite/[token]/index.get.test.ts, test/unit/server/api/invite/[token]/accept.post.test.ts
Extends API route test helpers with lending service mocks and createLoanSchema; updates DELETE tests to assert confirmActiveLoan is passed; adds loan creation tests (auth, validation, service args); adds invite preview/accept tests (token validation and service delegation).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 A lending library blooms in code,
With soft deletes and tokens to encode,
Invites zip through the wire,
With borrowed books climbing higher—
Share the joy, let friendships bode! 📚✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Add privacy-first lending history and invite flow' accurately and concisely summarizes the main change: a complete lending feature with history tracking and invite workflows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lending

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@niklhut
Copy link
Copy Markdown
Owner Author

niklhut commented May 20, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
test/unit/server/api/invite/[token]/accept.post.test.ts (1)

20-43: ⚡ Quick win

Add a missing-token validation test for the accept endpoint.

You already cover auth + success; adding a no-token case will lock in the 400 contract and prevent accidental service calls with undefined tokens.

Proposed test addition
 describe('server/api/invite/[token]/accept.post', () => {
   beforeEach(setupApiRouteTest)
   afterEach(cleanupApiRouteTest)

   itRequiresAuth(route, { params: { token: 'token-1' } })

   it('accepts a book invite for the signed-in user', async () => {
@@
     await expect(handler(makeEvent({ params: { token: 'token-1' } }))).resolves.toEqual(borrowed)
     expect(serviceMocks.acceptBookInvite).toHaveBeenCalledWith('token-1', 'user-1')
   })
+
+  it('rejects missing tokens', async () => {
+    mockLoggedInUser()
+    const handler = await importRoute(route)
+
+    await expect(handler(makeEvent())).rejects.toMatchObject({
+      statusCode: 400,
+      message: 'Invitation token is required'
+    })
+  })
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/server/api/invite/`[token]/accept.post.test.ts around lines 20 -
43, Add a test that calls the accept endpoint with no token and asserts it
returns a 400 and does not call the service; specifically, in
test/unit/server/api/invite/[token]/accept.post.test.ts add an it block that
imports the route via importRoute, invokes handler(makeEvent({ params: {} }))
(or params omitted), expects the promise to reject or resolve with a 400
error/response, and asserts serviceMocks.acceptBookInvite was not called;
reference the existing symbols route, importRoute, makeEvent, and
serviceMocks.acceptBookInvite to mirror the other tests' structure.
app/pages/i/[token].vue (1)

35-61: ⚡ Quick win

Remove redundant state reset before navigation.

Line 48 sets isAccepting.value = false before navigateTo, but the finally block (line 59) always resets it afterward. Remove the redundant line 48 to simplify the control flow.

♻️ Proposed fix
     })
     toast.add({
       title: 'Added to borrowed books',
       color: 'success'
     })
-    isAccepting.value = false
     await navigateTo({ path: '/library/loans', query: { view: 'borrowed' } })
   } catch (err: unknown) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/i/`[token].vue around lines 35 - 61, The acceptBook function
contains a redundant state reset: remove the premature assignment to
isAccepting.value = false that occurs before calling navigateTo, leaving the
final cleanup in the finally block to always reset isAccepting; update the
acceptBook function to rely on the existing finally block for resetting
isAccepting and keep the toast and navigateTo calls intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/components/BookLendingModal.vue`:
- Around line 24-28: The computed property tomorrowDate uses toISOString() which
is UTC-based and can produce the wrong calendar day for input type="date";
update the tomorrowDate computed (and any place using it as the min) to produce
a local "YYYY-MM-DD" string (e.g., build the value from date.getFullYear(),
date.getMonth()+1, date.getDate() with zero-padding or use
toLocaleDateString('en-CA')) after incrementing the day so the min date reflects
the local date instead of UTC.

In `@server/db/schema/domain.ts`:
- Around line 113-137: Add DB-level uniqueness by creating two unique partial
indexes on the loans table: a unique index on userBookId for active loans and a
unique index on acceptTokenHash when not null. In the table definition where
indexes are declared (refer to table, table.userBookId, table.status, and
table.acceptTokenHash), replace or add to the existing index(...) calls with
uniqueIndex(...) calls using a WHERE clause/partial predicate: one uniqueIndex
on table.userBookId with condition sql`${table.status} = 'active'`, and one
uniqueIndex on table.acceptTokenHash with condition sql`${table.acceptTokenHash}
IS NOT NULL` so concurrent writes cannot create multiple active loans or
duplicate non-null accept tokens.

In `@server/repositories/lending.repository.ts`:
- Around line 321-351: The invite preview and acceptance must verify the owner
copy still exists and isn't soft-deleted: in getInvitePreviewByHash (and the
sibling method around lines 354-379), extend the db query that reads from loans
to also confirm the owner's userBooks row for loan.ownerUserId and
loan.snapshotBookId is present and not soft-deleted (e.g., userBooks.removedAt
IS NULL or equivalent active flag), or alternatively mark unaccepted invites
cancelled during removeFromLibrary when confirmActiveLoan is true; if the owner
copy is missing treat the invite as invalid (return the same InvalidInviteError
or set canAccept=false and status='unavailable') so pending invites cannot be
accepted for removed owner copies.
- Around line 137-188: The createLoan flow currently uses a non-atomic preflight
check (snapshotForBook + existing query) and must be backed by a DB uniqueness
constraint on (userBookId, ownerUserId, status) for status='active' (partial
unique index) so the DB enforces a single active loan; then update createLoan to
handle uniqueness-violation errors from the insertion (the try/catch in the
Effect.tryPromise with operation 'createLoan.insert') by mapping the constraint
error to ActiveLoanExistsError (instead of only relying on the preflight), keep
or remove the preflight check as desired but ensure the insert error path
converts the DB constraint violation into new ActiveLoanExistsError and other
errors remain wrapped as DatabaseError.
- Around line 208-233: The returnLoan update allows marking invites that were
never accepted as returned; modify the where clause in returnLoan to also
require the invite was accepted by adding a check that the accept token is null
(e.g. include eq(loans.acceptTokenHash, null) alongside eq(loans.id, loanId),
eq(loans.ownerUserId, ownerUserId) and eq(loans.status, 'active')) so only truly
accepted/borrowed loans can be returned and pending invites cannot be bypassed.

In `@shared/utils/schemas.ts`:
- Around line 225-235: The refine() validation for dueAt currently compares a
Date parsed from nullableDateSchema against a local "today" midnight, causing
timezone-dependent failures for date-only inputs; update the validation to
compare calendar dates instead of absolute timestamps by either: (a) normalizing
value.dueAt to local date-only (extract year/month/day using the Date methods)
and compare those to today's local year/month/day, or (b) change
nullableDateSchema to parse date-only strings into a local Date (e.g., treat
'YYYY-MM-DD' as 'YYYY-MM-DDT00:00' in local time) before the refine check;
ensure you update the check within the .refine() for dueAt so it uses the
adjusted local-date comparison logic rather than direct >= Date comparisons.

---

Nitpick comments:
In `@app/pages/i/`[token].vue:
- Around line 35-61: The acceptBook function contains a redundant state reset:
remove the premature assignment to isAccepting.value = false that occurs before
calling navigateTo, leaving the final cleanup in the finally block to always
reset isAccepting; update the acceptBook function to rely on the existing
finally block for resetting isAccepting and keep the toast and navigateTo calls
intact.

In `@test/unit/server/api/invite/`[token]/accept.post.test.ts:
- Around line 20-43: Add a test that calls the accept endpoint with no token and
asserts it returns a 400 and does not call the service; specifically, in
test/unit/server/api/invite/[token]/accept.post.test.ts add an it block that
imports the route via importRoute, invokes handler(makeEvent({ params: {} }))
(or params omitted), expects the promise to reject or resolve with a 400
error/response, and asserts serviceMocks.acceptBookInvite was not called;
reference the existing symbols route, importRoute, makeEvent, and
serviceMocks.acceptBookInvite to mirror the other tests' structure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1801e69b-4d31-42e4-917f-88669663d4a8

📥 Commits

Reviewing files that changed from the base of the PR and between cdc0863 and 0c11250.

⛔ Files ignored due to path filters (2)
  • server/db/migrations/sqlite/0010_lending_history.sql is excluded by !server/db/migrations/**
  • server/db/migrations/sqlite/meta/_journal.json is excluded by !server/db/migrations/**
📒 Files selected for processing (32)
  • app/components/AppHeader.vue
  • app/components/BookCard.vue
  • app/components/BookLendingModal.vue
  • app/pages/(auth)/login.vue
  • app/pages/(auth)/register.vue
  • app/pages/i/[token].vue
  • app/pages/library/[id].vue
  • app/pages/library/borrowed.vue
  • app/pages/library/index.vue
  • app/pages/library/loans.vue
  • server/api/books/[id]/index.delete.ts
  • server/api/books/[id]/loans/index.post.ts
  • server/api/borrowed/index.get.ts
  • server/api/invite/[token]/accept.post.ts
  • server/api/invite/[token]/cover.get.ts
  • server/api/invite/[token]/index.get.ts
  • server/api/loans/[id]/cancel.post.ts
  • server/api/loans/[id]/return.post.ts
  • server/api/loans/index.get.ts
  • server/db/schema/domain.ts
  • server/repositories/book.repository.ts
  • server/repositories/lending.repository.ts
  • server/services/book.service.ts
  • server/services/lending.service.ts
  • server/utils/effect.ts
  • shared/types/book.ts
  • shared/utils/schemas.ts
  • test/unit/server/api/_helpers/api-route.ts
  • test/unit/server/api/books/[id]/index.delete.test.ts
  • test/unit/server/api/books/[id]/loans/index.post.test.ts
  • test/unit/server/api/invite/[token]/accept.post.test.ts
  • test/unit/server/api/invite/[token]/index.get.test.ts

Comment thread app/components/BookLendingModal.vue
Comment thread server/db/schema/domain.ts
Comment thread server/repositories/lending.repository.ts
Comment on lines +208 to +233
returnLoan: (loanId, ownerUserId) =>
Effect.gen(function* () {
const now = new Date()
const rows = yield* Effect.tryPromise({
try: () => dbService.db
.update(loans)
.set({
status: 'returned',
returnedAt: now,
acceptTokenHash: null,
updatedAt: now
})
.where(and(eq(loans.id, loanId), eq(loans.ownerUserId, ownerUserId), eq(loans.status, 'active')))
.returning(),
catch: error => new DatabaseError({
message: `Failed to return loan: ${error}`,
operation: 'returnLoan'
})
})

if (!rows[0]) {
return yield* Effect.fail(new LoanNotFoundError({ loanId }))
}

return toOwnerLoan(rows[0])
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block returnLoan for invites that were never accepted.

This path only checks status = 'active', so a pending invite can be marked as returned even though nobody ever borrowed it. That creates impossible history and bypasses the dedicated cancel flow.

Suggested guard
       returnLoan: (loanId, ownerUserId) =>
         Effect.gen(function* () {
           const now = new Date()
           const rows = yield* Effect.tryPromise({
             try: () => dbService.db
               .update(loans)
               .set({
                 status: 'returned',
                 returnedAt: now,
                 acceptTokenHash: null,
                 updatedAt: now
               })
-              .where(and(eq(loans.id, loanId), eq(loans.ownerUserId, ownerUserId), eq(loans.status, 'active')))
+              .where(and(
+                eq(loans.id, loanId),
+                eq(loans.ownerUserId, ownerUserId),
+                eq(loans.status, 'active'),
+                isNotNull(loans.acceptedAt)
+              ))
               .returning(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
returnLoan: (loanId, ownerUserId) =>
Effect.gen(function* () {
const now = new Date()
const rows = yield* Effect.tryPromise({
try: () => dbService.db
.update(loans)
.set({
status: 'returned',
returnedAt: now,
acceptTokenHash: null,
updatedAt: now
})
.where(and(eq(loans.id, loanId), eq(loans.ownerUserId, ownerUserId), eq(loans.status, 'active')))
.returning(),
catch: error => new DatabaseError({
message: `Failed to return loan: ${error}`,
operation: 'returnLoan'
})
})
if (!rows[0]) {
return yield* Effect.fail(new LoanNotFoundError({ loanId }))
}
return toOwnerLoan(rows[0])
}),
returnLoan: (loanId, ownerUserId) =>
Effect.gen(function* () {
const now = new Date()
const rows = yield* Effect.tryPromise({
try: () => dbService.db
.update(loans)
.set({
status: 'returned',
returnedAt: now,
acceptTokenHash: null,
updatedAt: now
})
.where(and(
eq(loans.id, loanId),
eq(loans.ownerUserId, ownerUserId),
eq(loans.status, 'active'),
isNotNull(loans.acceptedAt)
))
.returning(),
catch: error => new DatabaseError({
message: `Failed to return loan: ${error}`,
operation: 'returnLoan'
})
})
if (!rows[0]) {
return yield* Effect.fail(new LoanNotFoundError({ loanId }))
}
return toOwnerLoan(rows[0])
}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/repositories/lending.repository.ts` around lines 208 - 233, The
returnLoan update allows marking invites that were never accepted as returned;
modify the where clause in returnLoan to also require the invite was accepted by
adding a check that the accept token is null (e.g. include
eq(loans.acceptTokenHash, null) alongside eq(loans.id, loanId),
eq(loans.ownerUserId, ownerUserId) and eq(loans.status, 'active')) so only truly
accepted/borrowed loans can be returned and pending invites cannot be bypassed.

Comment thread server/repositories/lending.repository.ts
Comment thread shared/utils/schemas.ts Outdated
@niklhut niklhut changed the title Add privacy-first lending history and invite flow feat: Add privacy-first lending history and invite flow May 20, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/pages/library/loans.vue`:
- Around line 199-205: The card-level keydown handlers for Enter/Space are
allowing key events from nested controls (like the "Mark returned" button) to
bubble and trigger openOwnerBook(loan); update the card element's keydown
bindings to stop propagation by changing
`@keydown.enter.prevent`="openOwnerBook(loan)" and
`@keydown.space.prevent`="openOwnerBook(loan)" to
`@keydown.enter.prevent.stop`="openOwnerBook(loan)" and
`@keydown.space.prevent.stop`="openOwnerBook(loan)" so keyboard activation of the
inner button won't navigate away (note the button already has `@click.stop` but
keydown needs .stop too).

In `@shared/utils/schemas.ts`:
- Around line 181-193: The preprocessor for localNullableDateSchema currently
returns an empty string when the input is cleared, causing z.date() validation
to fail; update the preprocess function used by localNullableDateSchema so that
when val === '' it returns undefined (i.e., treat cleared due date as absent),
keep returning null for explicit null and continue converting valid date strings
to Date objects so the downstream z.date().nullable().optional() accepts unset
values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 82bae3ee-b41c-4d47-a5bb-de28772d9411

📥 Commits

Reviewing files that changed from the base of the PR and between 0c11250 and d5262cd.

⛔ Files ignored due to path filters (2)
  • server/db/migrations/sqlite/0011_lending_unique_indexes.sql is excluded by !server/db/migrations/**
  • server/db/migrations/sqlite/meta/_journal.json is excluded by !server/db/migrations/**
📒 Files selected for processing (7)
  • app/components/BookLendingModal.vue
  • app/pages/i/[token].vue
  • app/pages/library/loans.vue
  • server/db/schema/domain.ts
  • server/repositories/lending.repository.ts
  • shared/utils/schemas.ts
  • test/unit/server/api/invite/[token]/accept.post.test.ts
💤 Files with no reviewable changes (1)
  • app/pages/i/[token].vue

Comment thread app/pages/library/loans.vue Outdated
Comment thread shared/utils/schemas.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shared/utils/schemas.ts (1)

258-258: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Change .optional().default(false) to .default(false).optional() to match Zod v4 chaining guidelines.

In Zod v4, .optional() must be called after .default(), not before. The current chaining order (.optional().default(false)) is incorrect and can cause runtime mismatches. Change it to .default(false).optional().

Current code
  confirmActiveLoan: z.boolean().optional().default(false)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/utils/schemas.ts` at line 258, The Zod schema property
confirmActiveLoan uses the wrong chaining order (.optional().default(false));
change the chain to call .default(false) before .optional() so it becomes
.default(false).optional() to comply with Zod v4 guidelines and avoid runtime
mismatches; update the confirmActiveLoan property in the schema where it's
defined to use this ordering.
♻️ Duplicate comments (1)
app/pages/library/loans.vue (1)

204-205: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use .self on card keydown handlers to avoid keyboard-triggered misnavigation.

Line 204 and Line 205 still fire when Enter/Space originates from nested controls (like “Mark returned”). .stop on the parent handler does not filter by event target, so the card navigation can still run.

💡 Minimal fix
-                `@keydown.enter.prevent.stop`="openOwnerBook(loan)"
-                `@keydown.space.prevent.stop`="openOwnerBook(loan)"
+                `@keydown.enter.self.prevent`="openOwnerBook(loan)"
+                `@keydown.space.self.prevent`="openOwnerBook(loan)"
In Vue 3, does `@keydown.enter.prevent.stop` on a parent still run when the keydown starts on a focused child button? Is `@keydown.enter.self.prevent` the correct way to restrict handling to the parent element itself?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/pages/library/loans.vue` around lines 204 - 205, The card-level keydown
handlers for navigation call openOwnerBook(loan) even when Enter/Space originate
from focused child controls; update the attributes on the card element to use
the .self modifier so the handler only runs when the card itself is the event
target (e.g., change `@keydown.enter.prevent.stop`="openOwnerBook(loan)" and
`@keydown.space.prevent.stop`="openOwnerBook(loan)" to
`@keydown.enter.self.prevent.stop`="openOwnerBook(loan)" and
`@keydown.space.self.prevent.stop`="openOwnerBook(loan)"), ensuring nested buttons
like “Mark returned” won’t trigger openOwnerBook.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@shared/utils/schemas.ts`:
- Line 258: The Zod schema property confirmActiveLoan uses the wrong chaining
order (.optional().default(false)); change the chain to call .default(false)
before .optional() so it becomes .default(false).optional() to comply with Zod
v4 guidelines and avoid runtime mismatches; update the confirmActiveLoan
property in the schema where it's defined to use this ordering.

---

Duplicate comments:
In `@app/pages/library/loans.vue`:
- Around line 204-205: The card-level keydown handlers for navigation call
openOwnerBook(loan) even when Enter/Space originate from focused child controls;
update the attributes on the card element to use the .self modifier so the
handler only runs when the card itself is the event target (e.g., change
`@keydown.enter.prevent.stop`="openOwnerBook(loan)" and
`@keydown.space.prevent.stop`="openOwnerBook(loan)" to
`@keydown.enter.self.prevent.stop`="openOwnerBook(loan)" and
`@keydown.space.self.prevent.stop`="openOwnerBook(loan)"), ensuring nested buttons
like “Mark returned” won’t trigger openOwnerBook.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a955d221-6d85-4d32-9e84-7f388e86b9ee

📥 Commits

Reviewing files that changed from the base of the PR and between d5262cd and e7172eb.

📒 Files selected for processing (2)
  • app/pages/library/loans.vue
  • shared/utils/schemas.ts

@niklhut
Copy link
Copy Markdown
Owner Author

niklhut commented May 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread app/pages/library/borrowed.vue Outdated
Comment thread app/pages/library/loans.vue
Comment thread server/db/schema/domain.ts Outdated
@niklhut
Copy link
Copy Markdown
Owner Author

niklhut commented May 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/components/BorrowedLoansSection.vue`:
- Around line 149-151: The template currently renders "Returned {{
formatDate(book.returnedAt) }}" even when book.returnedAt is null, causing
"Returned null"; update the conditional in BorrowedLoansSection.vue so you only
call formatDate(book.returnedAt) when returnedAt is present/valid (e.g.,
book.returnedAt && formatDate(book.returnedAt)), and provide a fallback label
such as "Returned (date unknown)" or "Returned — unknown" when returnedAt is
missing; keep the check tied to the existing book.status === 'returned' branch
and reuse the existing formatDate helper.

In `@app/components/LoanedOutLoansSection.vue`:
- Around line 195-197: The template in LoanedOutLoansSection.vue can render
"Returned null" when formatDate(loan.returnedAt) returns null; update the
expression that builds the returned label (the line using loan.status and
formatDate) to provide a fallback when formatDate returns a falsy value (e.g.,
use a default like "date unknown" or "—") so that when loan.status ===
'returned' you render "Returned <fallback>" instead of "Returned null"; adjust
the inline ternary or move this logic into a small computed/helper near the
component (referencing loan.status, loan.returnedAt, and formatDate) to ensure a
safe display.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5022264d-f7ef-43ed-a2fe-0c5fc6254d20

📥 Commits

Reviewing files that changed from the base of the PR and between b287e2a and 3b86e19.

⛔ Files ignored due to path filters (2)
  • server/db/migrations/sqlite/0012_remove_loan_owner_note.sql is excluded by !server/db/migrations/**
  • server/db/migrations/sqlite/meta/_journal.json is excluded by !server/db/migrations/**
📒 Files selected for processing (8)
  • app/components/BorrowedLoansSection.vue
  • app/components/LoanedOutLoansSection.vue
  • app/pages/library/loans.vue
  • server/db/schema/domain.ts
  • server/repositories/lending.repository.ts
  • server/services/lending.service.ts
  • shared/utils/schemas.ts
  • test/unit/server/api/books/[id]/loans/index.post.test.ts
💤 Files with no reviewable changes (3)
  • server/db/schema/domain.ts
  • server/services/lending.service.ts
  • server/repositories/lending.repository.ts

Comment thread app/components/BorrowedLoansSection.vue
Comment thread app/components/LoanedOutLoansSection.vue
@niklhut
Copy link
Copy Markdown
Owner Author

niklhut commented May 25, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/components/LoanedOutLoansSection.vue (1)

20-34: ⚡ Quick win

Extract shared loan-display date helpers to avoid drift.

formatDate and returnedLabel are duplicated here and in BorrowedLoansSection.vue; this already caused mirrored fixes once. Moving them into a shared helper/composable will keep labels consistent and reduce regression risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/LoanedOutLoansSection.vue` around lines 20 - 34, Extract the
duplicated formatDate and returnedLabel functions into a single shared
helper/composable that exports those same-named functions (e.g., create a
useLoanDateHelpers module exporting formatDate and returnedLabel); keep the
exact behavior and TypeScript signatures (formatDate(value: Date | string |
null): string | null and returnedLabel(value: Date | string | null): string) and
then remove the local definitions from LoanedOutLoansSection.vue and
BorrowedLoansSection.vue and replace them with imports from the new helper (use
the exported function names formatDate and returnedLabel in both components so
existing call sites remain unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/components/LoanedOutLoansSection.vue`:
- Around line 20-34: Extract the duplicated formatDate and returnedLabel
functions into a single shared helper/composable that exports those same-named
functions (e.g., create a useLoanDateHelpers module exporting formatDate and
returnedLabel); keep the exact behavior and TypeScript signatures
(formatDate(value: Date | string | null): string | null and returnedLabel(value:
Date | string | null): string) and then remove the local definitions from
LoanedOutLoansSection.vue and BorrowedLoansSection.vue and replace them with
imports from the new helper (use the exported function names formatDate and
returnedLabel in both components so existing call sites remain unchanged).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b2b9f1b5-c5cc-40a4-963d-c6a7e3198aac

📥 Commits

Reviewing files that changed from the base of the PR and between b287e2a and 3443cae.

⛔ Files ignored due to path filters (2)
  • server/db/migrations/sqlite/0012_remove_loan_owner_note.sql is excluded by !server/db/migrations/**
  • server/db/migrations/sqlite/meta/_journal.json is excluded by !server/db/migrations/**
📒 Files selected for processing (8)
  • app/components/BorrowedLoansSection.vue
  • app/components/LoanedOutLoansSection.vue
  • app/pages/library/loans.vue
  • server/db/schema/domain.ts
  • server/repositories/lending.repository.ts
  • server/services/lending.service.ts
  • shared/utils/schemas.ts
  • test/unit/server/api/books/[id]/loans/index.post.test.ts
💤 Files with no reviewable changes (3)
  • server/db/schema/domain.ts
  • server/services/lending.service.ts
  • server/repositories/lending.repository.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant