🌐 Language: English · Español
Replaces a library's loan spreadsheet with a real application: a searchable catalogue, loans with actual business rules (overdue tracking, account blocks, a waiting list), e-mail reminders before a loan falls due, and record autofill from Open Library given just an ISBN.
A note on language: the interface, validation messages and API error codes are in Spanish — a deliberate choice, since the assessment this project was built for targets a Spanish-speaking library. This documentation is in English by default for portfolio visibility, with a full Spanish version available.
You only need Docker. No local JDK, Maven or Node required.
docker compose up --build| What | Where |
|---|---|
| Application | http://localhost:4200 |
| API + Swagger UI | http://localhost:8080/swagger-ui.html |
| Mailbox (MailHog) | http://localhost:8025 |
| Backend health | http://localhost:8080/actuator/health |
The first run takes a few minutes (it compiles both the backend and the frontend inside
the images). To stop everything and drop the data: docker compose down -v.
Created by the migrations. These are throwaway credentials for a throwaway database; there is no real secret anywhere in this repository.
| Password | Role | |
|---|---|---|
admin@libris.cl |
Admin123! |
ADMIN |
bibliotecario@libris.cl |
Biblio123! |
BIBLIOTECARIO (librarian) |
lector@libris.cl |
Demo123! |
BIBLIOTECARIO (librarian) |
The demo profile (on by default) also loads loans, late returns, reservations and one
blocked account, so the dashboards are not empty on first login.
Everything the application sends lands in MailHog: http://localhost:8025
To avoid waiting for the 08:00 cron, an ADMIN can trigger the scheduled jobs by hand from Administración → Resumen, or through the API:
curl -X POST http://localhost:8080/api/admin/notifications/due-soon-reminders \
-H "Authorization: Bearer $TOKEN"- Sign in as admin@libris.cl / Admin123!
- Catálogo → Agregar libro → ISBN
9780321356680→ Buscar información. It fills itself in: Effective Java, Joshua Bloch, 2008, with cover and subjects. - Confirm. The book is now available.
- Borrow it → the confirmation e-mail lands in MailHog.
- Open another session as
lector@libris.cland reserve that same book (it's out now). - Return it from the first session → the copy goes to RESERVADO and the reader who was waiting gets the book available notice.
- In Administración → Resumen, click Enviar recordatorios and check MailHog.
backend/ REST API in Spring Boot (Java 21)
frontend/ Angular 21 application (standalone + signals)
docs/ Data model and Postman collection
📐 Entity-relationship diagram, constraints and sample queries:
docs/data-model.md
com.libris
├── auth/ Registration, login, JWT
├── book/ Catalogue, ISBN
│ ├── metadata/ Port toward external catalogues + Open Library adapters
│ └── recommendation/ Content-based recommender
├── loan/ Loans
│ ├── policy/ Due-date calculation and derived status
│ └── rule/ Eligibility rules, one class per rule
├── reservation/ Waiting list
├── user/ Accounts, block policy, administration
├── stats/ Dashboard statistics
├── notification/ Events, templates, delivery and scheduled jobs
└── shared/ Business exceptions and the single error shape
Not as a label: each principle solved a concrete problem in this system.
SRP — one reason to change.
DueDateCalculator only computes loan date + 14 days. OverdueBlockPolicy only decides
whether a late return costs the block. LoanStatusResolver only translates a loan into
the status the interface shows. LoanService holds no rule at all: it orchestrates and
opens the transaction. Controllers carry no business logic.
OCP — open for extension, closed for modification.
The conditions a loan must pass are beans implementing
LoanRule; Spring injects
them as an ordered list. Adding a rule means adding a class — LoanService never
changes. The proof is in the history: the waiting-list rule
(ReservationHolderRule)
is contributed by the reservation module and plugs in without touching the loan module.
LSP — genuinely substitutable.
BookMetadataSource
has a total contract: it never throws. An outage, a timeout, a 429 or an unknown ISBN
all produce the same Optional.empty(). That is why the two Open Library implementations
are interchangeable, and why the catalogue keeps working when the third party does not.
ISP — interfaces sized to whoever uses them.
Instead of one NotificationService with five methods, there are three ports:
LoanNotifier,
AccountNotifier and ReservationNotifier. The scheduled jobs depend only on the first.
DIP — depend on abstractions.
The domain depends on EmailSender, TemplateRenderer, BookMetadataProvider and an
injected Clock. No rule imports JavaMailSender, RestClient, or calls
LocalDate.now() directly; that is what lets the tests pin time with Clock.fixed and
verify the 90-day window without depending on the day they happen to run.
| Rule | Lives in | Error returned |
|---|---|---|
| The copy must be on the shelf | BookAvailableRule |
409 BOOK_NOT_AVAILABLE |
| A held copy is only for its holder | ReservationHolderRule |
409 BOOK_RESERVED_FOR_ANOTHER_USER |
| The account must not be blocked | BorrowerNotBlockedRule |
409 USER_BLOCKED |
| At most 3 active loans | MaxActiveLoansRule |
409 MAX_ACTIVE_LOANS |
| ISBN must be unique | BookService + unique index |
409 DUPLICATE_ISBN |
| Only available copies can be deleted | BookService |
409 BOOK_NOT_DELETABLE |
On loan: the book moves to PRESTADO, the due date is set 14 days out, and an event
is published that sends the confirmation e-mail off the HTTP thread.
On return: a late return is recorded as a strike. On the third strike in 90 days
the account is blocked for one week and notified; an ADMIN can lift it early. If
someone was waiting for that title, the copy goes to RESERVADO for the first person in
the queue and they are notified by e-mail, instead of going back to DISPONIBLE.
The block is stored as blocked_until (a timestamp), not a boolean: it lapses on its
own, with no cleanup job required.
Five Thymeleaf templates under
templates/email/, built on a shared
layout using tables and inline styles (mail clients still don't support flexbox):
| Template | When |
|---|---|
loan-confirmation |
when the loan is registered |
due-soon-reminder |
daily job, 2 days before the due date |
overdue-notice |
daily job, once the loan is overdue |
account-blocked |
on the third strike |
book-available |
when a title someone was waiting for is freed up |
Delivery never blocks the HTTP request:
@Async(AsyncConfig.NOTIFICATION_EXECUTOR)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onLoanCreated(LoanCreatedEvent event) { ... }AFTER_COMMIT guarantees no e-mail can ever describe a loan that ended up rolled back,
and @Async on its own pool keeps a slow SMTP server from consuming the web server's
threads.
Both jobs are idempotent: they stamp reminder_sent_at / overdue_notice_sent_at as
they send, so running them twice on the same day never duplicates a notice.
GET /api/books/lookup/{isbn} previews without saving anything, and POST /api/books
accepts just the ISBN and fills in the rest.
Two sources behind the same port. The assessment illustrates the integration with
/isbn/{isbn}.json. That endpoint returns the edition: title, year and cover, but
not the author or the subjects, which live on the work and would cost three or four
chained calls. The primary source is therefore /api/books?jscmd=data, which answers
everything in one call, with the endpoint from the assessment kept as the fallback. Both
implement BookMetadataSource and are queried in order.
Details that took some digging and are now handled:
/isbn/{isbn}.jsonanswers with a 302 toward the canonical URL → the client follows redirects.- Open Library throttles anonymous traffic → a dedicated
User-Agentis sent. - Subjects arrive mixed with shelving codes (
Qa76.73.j38,005.13/3), which pollute the filters and skew the recommendations →SubjectSanitizerstrips them out.
Caching. @Cacheable over Caffeine (24 h, 1,000 entries). Empty results are never
cached: a momentary outage should not poison the cache for a whole day.
When it fails. Registering the book does not break: it saves with whatever the
person typed. Manual input always wins over what the external catalogue returns. If there
is neither manual nor external data, the API answers 400 INCOMPLETE_BOOK_DATA asking
for a title and an author. The preview endpoint does report the failure
(503 EXTERNAL_LOOKUP_FAILED), because there the caller explicitly asked for external
data.
Worst-case latency: 3 s per source, 6 s if neither answers.
Errors always share the same shape:
{
"timestamp": "2026-08-11T20:46:18.114Z",
"status": 409,
"code": "DUPLICATE_ISBN",
"message": "Ya existe un libro registrado con el ISBN 9780132350884",
"path": "/api/books",
"fieldErrors": { "isbn": "El ISBN no es válido" }
}code is stable and meant for the client to react to a specific rule without parsing the
message — which, matching the rest of the interface, is written in Spanish.
| Method | Path | Access |
|---|---|---|
| POST | /api/auth/register · /api/auth/login |
public |
| GET | /api/books · /api/books/{id} · /api/books/subjects |
authenticated |
| GET | /api/books/lookup/{isbn} |
authenticated |
| GET | /api/books/recommendations |
authenticated |
| POST | /api/books |
ADMIN |
| DELETE | /api/books/{id} |
ADMIN, only if DISPONIBLE |
| POST | /api/loans · GET /api/loans/mine · PUT /api/loans/{id}/return |
authenticated |
| POST | /api/reservations · GET /api/reservations/mine · DELETE /api/reservations/{id} |
authenticated |
| GET | /api/admin/stats |
ADMIN |
| GET | /api/admin/users · PUT /api/admin/users/{id}/unblock |
ADMIN |
| POST | /api/admin/notifications/{due-soon-reminders,overdue-notices} |
ADMIN |
Postman collection: docs/postman/ — 30 requests with tests,
including a Reglas de negocio (business rules) folder holding the cases that must
fail. Import the collection and the environment, run Login (ADMIN), and the token
saves itself.
Security. JWT HS256 over Spring Security, stateless, passwords hashed with BCrypt.
JWT_SECRET comes from an environment variable; if it is unset, the application
generates an ephemeral key and logs a WARN about it. That is what keeps
docker compose up a single command with no secret ever committed, at the cost of
invalidating tokens on every restart.
Angular 21, standalone, zoneless, with signals, @if/@for, and routes loaded on
demand. No UI or charting library: the doughnut and the trend line are hand-written
SVG. Initial bundle: ~95 kB compressed.
| Screen | What it does |
|---|---|
| Login / Register | mirrors the server's validation, "remember me" |
| Home | own metrics, upcoming due dates, recommendations |
| Catalogue | search, filters by status and subject, sorting, pagination |
| Add book | 3-step wizard with ISBN autofill |
| My loans | tabs, countdown, return action |
| My reservations | queue position, cancel, borrow |
| Administration | overview with charts, accounts and blocks, catalogue management |
An interceptor attaches the token to every call to /api (and only there: cover
images go to Open Library, and sending them the token would leak it). Another interceptor
turns any failure into a visible notice — there is no silent error. Every screen has its
own loading (skeletons shaped like the eventual content), error (with a retry
button) and empty states.
The catalogue filters live in the URL (/catalogo?q=clean&estado=DISPONIBLE), so a search
can be shared as a link and the back button does what you'd expect.
Content-based filtering, the classic baseline for recommender systems: a subject profile is built from the reader's loan history (weighted by recency, with an 180-day half-life), subjects are weighted with TF-IDF — "Software engineering," which appears in almost every book, counts for far less than "Distributed systems" — and results are ranked by cosine similarity.
No external service, no credentials, no cost. The interface explains every suggestion by showing the shared subjects, and an account with no history gets no invented recommendations: the panel simply does not appear.
cd backend && ./mvnw verify126 tests (101 unit + 25 integration).
| What it covers | With what |
|---|---|
| Loan, overdue and block rules | JUnit + Mockito with Clock.fixed |
| Duplicate ISBN, deletion, enrichment | BookServiceTest |
| Open Library: success, 404, 500, 429, malformed JSON, and timeout | MockWebServer |
| Catalogue and loan endpoints, 401/403 by role | MockMvc + Testcontainers (real PostgreSQL) |
| That the e-mail actually goes out, with the right subject and recipient | GreenMail |
Tests run against real PostgreSQL, not H2, because the schema uses partial unique indexes
and to_char: testing against a different engine would say nothing about the migrations
that actually ship.
Note on Windows: the MockWebServer tests need loopback sockets. On some machines with strict antivirus software they fail with
Unable to establish loopback connection. They pass without issue on Linux, in Docker and in CI; if this happens to you, run the suite inside a container.
The assessment asks for decisions to be documented rather than left as gaps. Here they are.
The loan links to the account by e-mail. The specification defines Loan with
borrowerName/borrowerEmail (no relation to AppUser), yet also asks for
/api/loans/mine and for blocking accounts. Both fields are kept exactly as specified and
resolved against AppUser by e-mail: the block applies to that account, and /mine
filters by the authenticated e-mail. An ADMIN may register a loan on behalf of another
account; a BIBLIOTECARIO only for themselves.
RESERVADO does not mean "free." A held copy can only be borrowed by the holder of
the reservation; the reservation moves to CUMPLIDO automatically once that loan is
registered.
Maximum of 3 active loans. Not in the specification: it comes from the interface,
which promises "you can have up to 3 active loans." Implemented as a configurable rule
(LIBRIS_MAX_ACTIVE_LOANS) rather than removing the text.
Overdue notice e-mail. The specification asks for four e-mails, but Loan includes
overdueNoticeSentAt: that field only makes sense if something actually sends that notice
and records it.
Three endpoints outside the listed set, required by the rules themselves and by the
screens: GET /api/admin/users and PUT /api/admin/users/{id}/unblock (the specification
requires that an ADMIN be able to lift a block), GET /api/reservations/mine (without it
"My reservations" cannot exist), and POST /api/admin/notifications/* (to demonstrate the
scheduled jobs without waiting for 08:00).
The reference design had elements that contradict the rules or that the specification does not ask for. What was done about each:
| In the design | What was done | Why |
|---|---|---|
| ADMINISTRACIÓN section visible to a librarian | Hidden unless ADMIN | Only ADMIN registers books and sees statistics |
| Global KPIs on "Inicio" | "Inicio" shows the reader's own metrics | /api/admin/stats is ADMIN-only: a librarian would see nothing but 403s |
| "Marcar como recibido" on a reservation | Replaced with "Pedir prestado" | No such endpoint exists; the reservation closes itself once the loan is registered |
| Doughnut with "Por vencer" nested inside "Activos" summing to 100% | Four mutually exclusive states | Otherwise the percentages count the same loan twice |
| Total 2,148 ≠ Available 1,258 + Borrowed 138 | Total = available + borrowed + reserved | Arithmetic consistency |
| Google sign-in, password recovery | Removed | Not requested, and would require OAuth credentials committed to the repository |
| Notification bell, "Actividad reciente," "Configuración" | Removed | Would need entities nobody asked for |
| Registration, "Reservar" and "Eliminar libro" were missing | Added | The specification does require those endpoints |
- Spring Boot 3.5, not 4.x: the 3.x line is the mature choice for springdoc and Testcontainers, and the assessment asks for "3.3+".
- No Lombok:
recordfor DTOs and explicit accessors on the entities. Less magic in a project meant to be read for evaluation. - Explicit nulls in the JSON:
returnDatetravels asnullrather than disappearing, so the client can tell "not returned yet" apart from "field absent." - The health check does not depend on SMTP: the API is perfectly usable with mail down, because delivery is asynchronous.
Every one of them has a default: docker compose up works with no file to create. To
customise, copy .env.example to .env.
| Variable | Default | What for |
|---|---|---|
FRONTEND_PORT / BACKEND_PORT |
4200 / 8080 |
published ports |
POSTGRES_DB / _USER / _PASSWORD |
libris |
database |
JWT_SECRET |
(empty) | HS256 key; empty ⇒ ephemeral + WARN |
JWT_EXPIRATION_MINUTES |
480 |
token lifetime |
SPRING_PROFILES_ACTIVE |
demo |
default to start with no sample data |
MAIL_HOST / MAIL_PORT |
mailhog / 1025 |
SMTP server |
LIBRIS_LOAN_DAYS |
14 |
loan length |
LIBRIS_MAX_ACTIVE_LOANS |
3 |
simultaneous loans |
LIBRIS_DUE_SOON_DAYS |
3 |
"due soon" window |
LIBRIS_REMINDER_DAYS_BEFORE |
2 |
reminder lead time |
LIBRIS_OVERDUE_LIMIT |
3 |
strikes that trigger a block |
LIBRIS_OVERDUE_WINDOW_DAYS |
90 |
rolling window for strikes |
LIBRIS_BLOCK_DAYS |
7 |
how long the block lasts |
TZ |
America/Santiago |
due dates are business dates |
# Database and mailbox
docker compose up -d postgres mailhog
# Backend (requires JDK 21)
cd backend && ./mvnw spring-boot:run
# Frontend (requires Node 20.19+/22.12+/24+)
cd frontend && npm install && npm startnpm start serves Angular on port 4200 and proxies /api to port 8080 via
proxy.conf.json.
Built with GitFlow: main ← release/* ← develop ← feature/* and fix/*, merged
with --no-ff to keep each branch's history intact. Commits are short, in English, and
follow a conventional commits style.
git log --graph --oneline --all