Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
}
}
}
12 changes: 12 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(git checkout *)",
"Bash(git stash *)",
"Bash(npx playwright *)",
"Bash(npx tsc *)",
"Skill(update-config)",
"Skill(update-config:*)"
]
}
}
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,10 @@ docker
.vscode

data/ml_models/
phase1_datasets/
phase1_datasets/

recommenderService/evaluation_results/

recommenderService/**/__pycache__/

.claude/
87 changes: 87 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Services at a Glance

| Service | Stack | Port | Storage |
|---|---|---|---|
| `apiGateway` | Spring Cloud Gateway | 9000 | — |
| `bookService` | Spring Boot 3 / JPA | 8080 | MySQL |
| `reviewService` | Spring Boot 3 / MongoDB | 8081 | MongoDB |
| `recommenderService` | FastAPI (Python) | 8082 | — |
| `book-bazaar` | Angular 20 | 4200 | — |

## Running the Stack

```bash
# Start all infrastructure + services
docker-compose up -d

# Production build (builds JARs/images first)
docker-compose -f docker-compose.prod.yml up -d
```

Infrastructure brought up by Docker: MySQL (3307), Keycloak (8181), MongoDB (27018), Kafka (9092), Schema Registry (8085), Kafka UI (8083), Prometheus (9090), Grafana (3000), Mailhog (8025).

## Build & Test Commands

**Java services** (run from each service directory):
```bash
mvn clean package -DskipTests # build JAR
mvn test # run tests
mvn test -Dtest=MyTestClass # run a single test class
```
Spring profiles: `dev` (default) and `prod`. Backend services read profile-specific config from `src/main/resources/application-dev.yml` / `application-prod.yml`.

**Angular frontend** (`book-bazaar/`):
```bash
npm start # dev server at localhost:4200
npm run build # production build
npm test # Karma/Jasmine unit tests
```

**Python recommender** (`recommenderService/`):
```bash
pip install -r requirements.txt
python main.py
```

## Architecture

### Request Flow
Browser → Angular (4200) → **API Gateway** (9000) → `book-service` or `review-service`

The gateway validates JWT tokens issued by Keycloak and relays them to backend services via a token relay filter. Service-to-service calls use OpenFeign clients with client-credentials flow.

### Async Scoring Pipeline (Kafka)
1. `reviewService` publishes a `ReviewScoringRequest` (Avro) to `review-scoring-requests` after review **create** or text **update**.
2. `recommenderService` consumes it, runs cosine similarity with `all-MiniLM-L6-v2`, and publishes a `ReviewScoringResult` to `review-scoring-results`.
3. `reviewService` (`ReviewScoringResultListener`) consumes the result and persists it into `Review.scoringResult`.

Avro schemas live in each service's `src/main/resources/avro/`. The `avro-maven-plugin` generates Java POJOs at `generate-sources` phase.

### Key Patterns

**AbstractController / AbstractService** — all CRUD controllers and services extend base classes that provide pagination, soft-delete (archival via `archived` flag), and lifecycle hooks (`beforeCreate`, `afterCreate`, `beforeUpdate`, `beforeDelete`, `afterDelete`). Prefer adding logic in these hooks rather than overriding the full CRUD method.

**Filtering** — query string filters use the syntax `property:value` / `property!=value` / `property_=value` (contains). Multi-value OR uses `||`. Parsed by a `SearchCriteria` / `FilterableProperty` spec builder in each service.

**DTOs** — each entity has a `{Entity}Request` (input) and `{Entity}Response` (output) class; conversion goes through a `Mapper<Entity, Response, Request>` injected by the controller base class.

**Review metrics** — `ReviewMetricsService` keeps denormalised rating aggregates on the book side; it is called on every review create/update/delete.

### Auth
Keycloak realm `e-library` issues JWTs. Roles: `USER`, `ADMIN`. The gateway and each service are configured as OAuth2 resource servers pointing at `http://localhost:8181/realms/e-library`.

## Key File Locations

| What | Where |
|---|---|
| Gateway routes | `apiGateway/src/main/resources/application.yml` |
| Kafka topic names | `reviewService/src/main/resources/application-dev.yml` → `kafka.topics.*` |
| Avro schemas | `reviewService/src/main/resources/avro/` |
| Keycloak realm config | `infrastructure/keycloak/` |
| Prometheus config | `infrastructure/prometheus/prometheus-dev.yml` |
| MySQL init schema | `infrastructure/init.sql` |
| ML model config | `recommenderService/config.py` |
8 changes: 8 additions & 0 deletions book-bazaar/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { SearchBooks } from './components/search-books/search-books';
import { BookDetails } from './components/book-details/book-details';
import { ReviewForm } from './components/review-form/review-form';
import { MyReviews } from './components/my-reviews/my-reviews';
import { AuthorsPage } from './components/authors/authors';
import { AuthorDetails } from './components/author-details/author-details';

export const routes: Routes = [
{
Expand All @@ -12,6 +14,12 @@ export const routes: Routes = [
{
path: "search", component: SearchBooks
},
{
path: "authors", component: AuthorsPage
},
{
path: "authors/:authorId", component: AuthorDetails
},
{
path: "book-details/:bookId", component: BookDetails
},
Expand Down
Empty file.
90 changes: 90 additions & 0 deletions book-bazaar/src/app/components/author-details/author-details.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
@if (loading()) {
<div class="flex justify-center items-center h-[80vh]">
<mat-spinner diameter="50"></mat-spinner>
</div>
} @else if (author(); as author) {

<!-- Hero -->
<section class="bg-gradient-to-br from-primary/10 via-white to-tertiary/10 py-12 px-4">
<div class="max-w-5xl mx-auto">

<a routerLink="/authors"
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-primary transition-colors mb-8 no-underline">
<mat-icon class="scale-90">chevron_left</mat-icon>
All Authors
</a>

<div class="flex flex-col sm:flex-row items-center sm:items-start gap-8">

<div
class="w-28 h-28 rounded-full flex items-center justify-center text-white font-bold text-4xl shrink-0 shadow-lg"
[style]="getAvatarStyle(author.name)">
{{ getInitials(author.name) }}
</div>

<div class="flex-1 text-center sm:text-left">
<h1 class="font-serif text-4xl sm:text-5xl font-bold text-gray-900 mb-4">
{{ author.name }}
</h1>

@if (author.bio) {
<p class="text-gray-600 text-base leading-relaxed max-w-2xl mb-6">
{{ author.bio }}
</p>
} @else {
<p class="text-gray-400 italic mb-6">No biography available.</p>
}

<button mat-stroked-button color="primary" class="!rounded-full" (click)="browseAllBooks()">
<mat-icon>search</mat-icon>
Browse all books by {{ author.name }}
</button>
</div>

</div>
</div>
</section>

<!-- Books grid -->
<main class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">

<div class="flex items-baseline gap-3 mb-8">
<h2 class="font-serif text-2xl font-bold text-gray-900">Books</h2>
@if (!booksLoading()) {
<span class="text-sm text-gray-400">{{ totalBooks() }} title{{ totalBooks() !== 1 ? 's' : '' }}</span>
}
</div>

@if (booksLoading()) {
<div class="flex justify-center py-24">
<mat-spinner diameter="44" color="primary"></mat-spinner>
</div>
} @else if (books().length === 0) {
<div class="flex flex-col items-center justify-center py-24 text-center text-gray-400 gap-4">
<mat-icon class="!text-6xl !w-16 !h-16">auto_stories</mat-icon>
<p class="text-lg font-medium">No books found for this author.</p>
</div>
} @else {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@for (book of books(); track book.id) {
<app-book-card [book]="book" />
}
</div>

@if (totalBooks() > pageSize()) {
<div class="flex justify-center mt-10">
<mat-paginator
[length]="totalBooks()"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[12, 24, 48]"
(page)="onPageChange($event)"
showFirstLastButtons
aria-label="Select page of books">
</mat-paginator>
</div>
}
}

</main>
}
117 changes: 117 additions & 0 deletions book-bazaar/src/app/components/author-details/author-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { map } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import { Author } from '../../model/author';
import { Book } from '../../model/book';
import { AuthorService } from '../../services/author/author-service';
import { BookService } from '../../services/book/book-service';
import { BookCard } from '../book-card/book-card';

const AVATAR_GRADIENTS = [
['#8b5cf6', '#7c3aed'],
['#3b82f6', '#4338ca'],
['#10b981', '#0d9488'],
['#f97316', '#d97706'],
['#f43f5e', '#db2777'],
['#06b6d4', '#0284c7'],
];

@Component({
selector: 'app-author-details',
imports: [
RouterLink,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatPaginatorModule,
BookCard,
],
templateUrl: './author-details.html',
styleUrl: './author-details.css',
})
export class AuthorDetails {
author = signal<Author | null>(null);
books = signal<Book[]>([]);
totalBooks = signal<number>(0);
loading = signal<boolean>(true);
booksLoading = signal<boolean>(false);
pageIndex = signal<number>(0);
pageSize = signal<number>(12);

private route = inject(ActivatedRoute);
private router = inject(Router);
private authorService = inject(AuthorService);
private bookService = inject(BookService);

private authorId = toSignal(
this.route.paramMap.pipe(map(p => Number(p.get('authorId'))))
);

ngOnInit(): void {
const id = this.authorId();
if (id && id > 0) {
this.loadAuthor(id);
}
}

private loadAuthor(id: number): void {
this.loading.set(true);
this.authorService.getAuthorById(id).subscribe({
next: (author) => {
this.author.set(author);
this.loading.set(false);
this.loadBooks(author.name!);
},
error: () => {
this.loading.set(false);
},
});
}

loadBooks(authorName: string): void {
this.booksLoading.set(true);
this.bookService
.getBooks({ author: authorName }, this.pageIndex(), this.pageSize(), 'title,asc')
.subscribe({
next: (response) => {
this.books.set(response.items);
this.totalBooks.set(response.total);
this.booksLoading.set(false);
},
error: () => {
this.books.set([]);
this.booksLoading.set(false);
},
});
}

onPageChange(event: PageEvent): void {
this.pageIndex.set(event.pageIndex);
this.pageSize.set(event.pageSize);
if (this.author()?.name) {
this.loadBooks(this.author()!.name!);
}
}

browseAllBooks(): void {
this.router.navigate(['/search'], { queryParams: { author: this.author()?.name } });
}

getInitials(name: string = ''): string {
return name
.split(' ')
.slice(0, 2)
.map(w => w[0]?.toUpperCase() ?? '')
.join('');
}

getAvatarStyle(name: string = ''): string {
const [from, to] = AVATAR_GRADIENTS[name.charCodeAt(0) % AVATAR_GRADIENTS.length];
return `background: linear-gradient(135deg, ${from}, ${to})`;
}
}
Empty file.
Loading
Loading