diff --git a/book-bazaar/public/author-placeholder.jpg b/book-bazaar/public/author-placeholder.jpg new file mode 100644 index 0000000..4dc0d10 Binary files /dev/null and b/book-bazaar/public/author-placeholder.jpg differ diff --git a/book-bazaar/src/app/app.config.ts b/book-bazaar/src/app/app.config.ts index f5b4220..8b534fa 100644 --- a/book-bazaar/src/app/app.config.ts +++ b/book-bazaar/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; -import { provideRouter, withViewTransitions } from '@angular/router'; +import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router'; import { routes } from './app.routes'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; @@ -14,7 +14,13 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideRouter(routes, withViewTransitions()), + provideRouter( + routes, + withViewTransitions(), + withInMemoryScrolling({ + scrollPositionRestoration: 'disabled', + anchorScrolling: 'enabled' + })), provideHttpClient(withInterceptors([includeBearerTokenInterceptor])), diff --git a/book-bazaar/src/app/app.routes.ts b/book-bazaar/src/app/app.routes.ts index 4e796ad..0ea4f52 100644 --- a/book-bazaar/src/app/app.routes.ts +++ b/book-bazaar/src/app/app.routes.ts @@ -1,7 +1,9 @@ import { Routes } from '@angular/router'; -import {Home} from './components/home/home'; -import {SearchBooks} from './components/search-books/search-books'; -import {BookDetails} from './components/book-details/book-details'; +import { Home } from './components/home/home'; +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'; export const routes: Routes = [ { @@ -12,5 +14,11 @@ export const routes: Routes = [ }, { path: "book-details/:bookId", component: BookDetails + }, + { + path: "book-details/:bookId/review", component: ReviewForm + }, + { + path: "my-reviews", component: MyReviews } ]; diff --git a/book-bazaar/src/app/app.ts b/book-bazaar/src/app/app.ts index 8cd94c0..44b5e76 100644 --- a/book-bazaar/src/app/app.ts +++ b/book-bazaar/src/app/app.ts @@ -1,7 +1,9 @@ -import { Component, signal } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, inject, signal } from '@angular/core'; +import { NavigationEnd, Router, RouterOutlet, Scroll } from '@angular/router'; import {Header} from './components/header/header'; import {Footer} from './components/footer/footer'; +import { ViewportScroller } from '@angular/common'; +import { filter } from 'rxjs'; @Component({ selector: 'app-root', @@ -10,4 +12,39 @@ import {Footer} from './components/footer/footer'; styleUrl: './app.css' }) export class App { + private router = inject(Router); + private viewportScroller = inject(ViewportScroller); + + private currentPath = ''; + + constructor() { + this.router.events.pipe( + filter((e): e is Scroll => e instanceof Scroll) + ).subscribe(e => { + + if (e.position) { + this.viewportScroller.scrollToPosition(e.position); + } else if (e.anchor) { + this.viewportScroller.scrollToAnchor(e.anchor); + } else { + const url = (e.routerEvent instanceof NavigationEnd) + ? e.routerEvent.urlAfterRedirects + : e.routerEvent.url; + + const newPath = this.stripParams(url); + + // Скролимо нагору ТІЛЬКИ якщо змінився шлях (наприклад Home -> Search) + // Якщо шлях той самий (/search -> /search?genre=Fantasy), скрол не чіпаємо + if (newPath !== this.currentPath) { + this.viewportScroller.scrollToPosition([0, 0]); + } + + this.currentPath = newPath; + } + }); + } + + private stripParams(url: string): string { + return url.split('?')[0]; + } } diff --git a/book-bazaar/src/app/components/book-card/book-card.ts b/book-bazaar/src/app/components/book-card/book-card.ts index 73ac458..4639ce5 100644 --- a/book-bazaar/src/app/components/book-card/book-card.ts +++ b/book-bazaar/src/app/components/book-card/book-card.ts @@ -15,26 +15,34 @@ import { CurrencyPipe } from '@angular/common'; styleUrl: './book-card.css', }) export class BookCard { - book = input.required(); - - private router = inject(Router); + book = input.required(); - searchByGenre(event: Event, genre: Genre) { - event.stopPropagation(); // Зупиняємо спливання події, щоб не спрацював клік по картці - event.preventDefault(); // Запобігаємо дефолтній поведінці посилання - - if (genre.name) { - this.router.navigate(['/search'], { queryParams: { genre: genre.name } }); - } + private router = inject(Router); + + searchByGenre(event: Event, genre: Genre) { + event.stopPropagation(); // Зупиняємо спливання події, щоб не спрацював клік по картці + event.preventDefault(); // Запобігаємо дефолтній поведінці посилання + + if (genre.name) { + this.router.navigate(['/search'], { queryParams: { genre: genre.name } }).then(() => window.scroll({ + top: 450, + left: 0, + behavior: 'smooth' + })); } + } - // Перехід на пошук по категорії - searchByCategory(event: Event, category: Category) { - event.stopPropagation(); - event.preventDefault(); + // Перехід на пошук по категорії + searchByCategory(event: Event, category: Category) { + event.stopPropagation(); + event.preventDefault(); - if (category.name) { - this.router.navigate(['/search'], { queryParams: { category: category.name } }); - } + if (category.name) { + this.router.navigate(['/search'], { queryParams: { category: category.name } }).then(() => window.scroll({ + top: 450, + left: 0, + behavior: 'smooth' + })); } + } } diff --git a/book-bazaar/src/app/components/book-details/book-details.html b/book-bazaar/src/app/components/book-details/book-details.html index 92d7ecf..ca151f0 100644 --- a/book-bazaar/src/app/components/book-details/book-details.html +++ b/book-bazaar/src/app/components/book-details/book-details.html @@ -5,18 +5,18 @@ } @else if (book(); as book) {
-
+
-
+
-
+
{{ book.price | currency:'USD' }}
@@ -25,7 +25,7 @@
-

+

{{ book.title }}

@@ -37,21 +37,25 @@

-
- star - star - star - star - star_half +
+ @for (_ of [1,2,3,4,5]; track $index) { + + {{ getStarIcon($index) }} + + }
-
- {{ rating }} - - {{ totalRatings }} ratings +
+ + {{ (metrics()?.averageRating | number:'1.1-1') || '0.0' }} + + + + {{ metrics()?.totalReviews || 0 }} ratings +
-
+

{{ book.description }}

@@ -66,14 +70,16 @@

-

About the author

+

About the author

-
- Author +
+ Author
-
{{ book.author?.name }}
-

{{ book.author?.bio || 'No biography available.' }}

+
{{ book.author?.name }}
+

+ {{ book.author?.bio || 'No biography available for this author.' }} +

@@ -81,55 +87,219 @@

About the author

-

Book Details

-
- +

Book Details

+
-
Publisher
-
{{ book.publisher?.name }}
+
Publisher
+
{{ book.publisher?.name || 'Unknown' }}
-
-
ISBN
-
{{ book.isbn }}
+
ISBN
+
{{ book.isbn || 'N/A' }}
-
-
Published
-
{{ book.releaseDate | date:'longDate' }}
+
Published
+
{{ book.releaseDate | date:'longDate' }}
- -
+
-
-

Ratings & Reviews

- -
-
- person -
-

What do you think?

+
+
+ + + +
+ +
+
+

Community Reviews

+ +
+
+ @for (_ of [1,2,3,4,5]; track $index) { + + {{ getStarIcon($index) }} + + } +
+ + {{ (metrics()?.averageRating | number:'1.1-1') || '0.0' }} + + + {{ metrics()?.totalReviews || 0 }} reviews + +
+ +
+ @for (star of [5, 4, 3, 2, 1]; track star) { + - } +
+
+
- + + {{ getStarPercentage(star) | number:'1.0-0' }}% + + + ({{ getStarCount(star) }}) + + + } +
+
+ +
+

+ {{ currentUserReview() ? 'Your Review' : 'What do you think?' }} +

+

+ {{ currentUserReview() ? 'You have already rated this book' : 'Share your opinion with the community' }} +

+ +
+ @for (star of [1,2,3,4,5]; track star) { + + }
+ + +
+
+ +
+ +
+

+ Reviews + @if(selectedRatingFilter()) { + + {{ selectedRatingFilter() }} stars only + + + } +

+ +
+ + + + + + + +
+
+ + @if (reviewsLoading()) { +
+ +
+ } + + @else if (reviews().length === 0) { +
+ chat_bubble_outline +

No reviews yet.

+

Be the first to share your thoughts!

+
+ } + + @else { +
+ @for (review of reviews(); track review.id) { +
+
+
+
+ @if(review.avatarUrl) { + User + } @else { + + {{ (review.firstName?.charAt(0) || 'U') }} + + } +
+
+
+ {{ review.firstName || 'User' }} {{ review.lastName || '' }} +
+
+ {{ review.createdAt | date:'mediumDate' }} +
+
+
+
+ +
+ @for (_ of [1,2,3,4,5]; track $index) { + + {{ $index < review.rating ? 'star' : 'star_border' }} + + } +
+ +
+ {{ review.text }} +
+ +
+
+ } +
+ +
+ + +
+ }
diff --git a/book-bazaar/src/app/components/book-details/book-details.ts b/book-bazaar/src/app/components/book-details/book-details.ts index b7d7bd8..113c97a 100644 --- a/book-bazaar/src/app/components/book-details/book-details.ts +++ b/book-bazaar/src/app/components/book-details/book-details.ts @@ -1,5 +1,5 @@ import { Component, inject, OnInit, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { BookService } from '../../services/book/book-service'; import { Book } from '../../model/book'; import { MatButtonModule } from '@angular/material/button'; @@ -7,7 +7,17 @@ import { MatIconModule } from '@angular/material/icon'; import { MatChipsModule } from '@angular/material/chips'; import { MatDividerModule } from '@angular/material/divider'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { DatePipe, CurrencyPipe } from '@angular/common'; +import { ReviewService } from '../../services/review/review-service'; +import { Review } from '../../model/review'; +import { ReviewMetrics } from '../../model/review-metric'; +import { PageEvent, MatPaginator } from '@angular/material/paginator'; +import { MatFormField } from "@angular/material/input"; +import { MatOption } from "@angular/material/core"; +import { MatSelectModule } from '@angular/material/select'; +import { DatePipe, CurrencyPipe, NgClass, DecimalPipe } from '@angular/common' +import { UserService } from '../../services/user/userService'; +import { ReviewRequest } from '../../model/review-request'; +import { MatMenuModule } from '@angular/material/menu'; @Component({ selector: 'app-book-details', @@ -18,31 +28,52 @@ import { DatePipe, CurrencyPipe } from '@angular/common'; MatChipsModule, MatDividerModule, MatProgressSpinnerModule, - DatePipe, - CurrencyPipe + DatePipe, + CurrencyPipe, + MatPaginator, + MatFormField, + MatOption, + MatSelectModule, + DecimalPipe, + NgClass, + MatMenuModule ], templateUrl: './book-details.html', styleUrl: './book-details.css', }) export class BookDetails implements OnInit { + protected router = inject(Router); private route = inject(ActivatedRoute); private bookService = inject(BookService); + private reviewService = inject(ReviewService); book = signal(null); loading = signal(true); - // Для відображення рейтингу (поки що заглушка, пізніше підтягнемо з бекенду) - rating = 4.4; - totalRatings = 1250; + userService = inject(UserService); // Інжект юзера + currentUserReview = signal(null); + + reviews = signal([]); + metrics = signal(null); + reviewsTotal = signal(0); + reviewsLoading = signal(false); + + pageIndex = signal(0); + pageSize = signal(10); + currentSort = signal('createdAt,desc'); + selectedRatingFilter = signal(undefined); - // Для інтерактивних зірок (What do you think?) userRating = signal(0); hoverRating = signal(0); ngOnInit(): void { const id = this.route.snapshot.paramMap.get('bookId'); if (id) { - this.loadBook(Number(id)); + const bookId = Number(id); + this.loadBook(bookId); + this.loadMetrics(bookId); + this.loadReviews(bookId); + this.checkUserReview(bookId); } } @@ -60,11 +91,142 @@ export class BookDetails implements OnInit { }); } - // Логіка для зірок + private loadMetrics(id: number) { + this.reviewService.getBookMetrics(id).subscribe({ + next: (data) => this.metrics.set(data), + error: () => console.log('No metrics found or error') + }); + } + + loadReviews(bookId: number) { + this.reviewsLoading.set(true); + this.reviewService.getReviews( + bookId, + this.pageIndex(), + this.pageSize(), + this.currentSort(), + this.selectedRatingFilter() + ).subscribe({ + next: (page) => { + this.reviews.set(page.items); + this.reviewsTotal.set(page.total); + this.reviewsLoading.set(false); + }, + error: (err) => { + console.error(err); + this.reviewsLoading.set(false); + } + }); + } + + private checkUserReview(bookId: number) { + if (this.userService.isLoggedIn()) { + const user = this.userService.userProfile(); + if (user) { + const userId = user.id; + this.reviewService.getUserReview(bookId, userId!).subscribe({ + next: (review) => { + this.currentUserReview.set(review); + if (review) { + this.userRating.set(review.rating); // Встановлюємо зірки + } + } + }); + } + } + } + + onPageChange(event: PageEvent) { + this.pageIndex.set(event.pageIndex); + this.pageSize.set(event.pageSize); + if (this.book()) { + this.loadReviews(this.book()!.id!); + } + } + + onSortChange(sortValue: string) { + this.currentSort.set(sortValue); + this.pageIndex.set(0); + if (this.book()) { + this.loadReviews(this.book()!.id!); + } + } + + toggleRatingFilter(star: number) { + // Якщо клікнули на вже вибраний фільтр - знімаємо його + if (this.selectedRatingFilter() === star) { + this.selectedRatingFilter.set(undefined); + } else { + this.selectedRatingFilter.set(star); + } + + this.pageIndex.set(0); + if (this.book()) { + this.loadReviews(this.book()!.id!); + } + } + + getStarPercentage(star: number): number { + const m = this.metrics(); + if (!m || m.totalReviews === 0) return 0; + + const count = m.reviewCountsRating[star.toString()] || 0; + return (count / m.totalReviews) * 100; + } + + getStarIcon(index: number): string { + const rating = this.metrics()?.averageRating || 0; + const rounded = Math.round(rating * 2) / 2; + + if (rounded >= index + 1) { + return 'star'; + } else if (rounded >= index + 0.5) { + return 'star_half'; + } else { + return 'star_border'; + } + } + + getStarCount(star: number): number { + return this.metrics()?.reviewCountsRating[star.toString()] || 0; + } + setRating(star: number) { + if (!this.userService.isLoggedIn()) { + this.userService.login(); // Або показати повідомлення + return; + } + this.userRating.set(star); - console.log(`User rated: ${star}`); - // Тут буде виклик методу для збереження рейтингу + + const request: ReviewRequest = { + bookId: this.book()!.id!, + rating: star, + text: this.currentUserReview()?.text || '' + }; + + const review = this.currentUserReview(); + + const obs$ = review + ? this.reviewService.updateReview(review.id, request) + : this.reviewService.createReview(request); + + obs$.subscribe({ + next: (savedReview) => { + this.currentUserReview.set(savedReview); + console.log('Rating saved'); + this.loadMetrics(this.book()!.id!); + this.loadReviews(this.book()!.id!) + } + }); + } + + writeReview() { + if (!this.userService.isLoggedIn()) { + this.userService.login(); + return; + } + this.router.navigate(['/book-details', this.book()?.id, 'review']); } setHoverRating(star: number) { @@ -75,8 +237,20 @@ export class BookDetails implements OnInit { this.hoverRating.set(0); } - writeReview() { - console.log('Open review dialog'); - // Тут відкриємо діалог написання рецензії + scrollToTarget(): void { + const element = document.getElementById('reviews'); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + + getSortLabel(value: string): string { + switch (value) { + case 'createdAt,desc': return 'Newest first'; + case 'createdAt,asc': return 'Oldest first'; + case 'rating,desc': return 'Highest rated'; + case 'rating,asc': return 'Lowest rated'; + default: return 'Sort by'; + } } } \ No newline at end of file diff --git a/book-bazaar/src/app/components/header/header.html b/book-bazaar/src/app/components/header/header.html index b3dba3e..f4ff325 100644 --- a/book-bazaar/src/app/components/header/header.html +++ b/book-bazaar/src/app/components/header/header.html @@ -1,44 +1,51 @@ -
+
+
@if (userService.isLoggedIn()) { - + - - +
+ @if (userService.avatarUrl()) { + Avatar + } @else { + person + } +
+ + + + + + + } @else { -
- - -
+
+ + +
}
diff --git a/book-bazaar/src/app/components/my-reviews/my-reviews.css b/book-bazaar/src/app/components/my-reviews/my-reviews.css new file mode 100644 index 0000000..e69de29 diff --git a/book-bazaar/src/app/components/my-reviews/my-reviews.html b/book-bazaar/src/app/components/my-reviews/my-reviews.html new file mode 100644 index 0000000..d1a2dfb --- /dev/null +++ b/book-bazaar/src/app/components/my-reviews/my-reviews.html @@ -0,0 +1,73 @@ +
+ +
+
+

My Reviews

+

+ You have written {{ totalReviews() }} reviews +

+
+ +
+ + + + + + + + +
+
+ +
+ + @if (loading()) { +
+ +
+ } + + @else if (reviews().length === 0) { +
+ rate_review +

No reviews yet

+

Start reading and share your thoughts with the world!

+ Browse Books +
+ } + + @else { +
+ @for (review of reviews(); track review.id) { + + + } +
+ +
+ + +
+ } +
+
\ No newline at end of file diff --git a/book-bazaar/src/app/components/my-reviews/my-reviews.spec.ts b/book-bazaar/src/app/components/my-reviews/my-reviews.spec.ts new file mode 100644 index 0000000..782edb4 --- /dev/null +++ b/book-bazaar/src/app/components/my-reviews/my-reviews.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MyReviews } from './my-reviews'; + +describe('MyReviews', () => { + let component: MyReviews; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MyReviews] + }) + .compileComponents(); + + fixture = TestBed.createComponent(MyReviews); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/book-bazaar/src/app/components/my-reviews/my-reviews.ts b/book-bazaar/src/app/components/my-reviews/my-reviews.ts new file mode 100644 index 0000000..1be9ff7 --- /dev/null +++ b/book-bazaar/src/app/components/my-reviews/my-reviews.ts @@ -0,0 +1,112 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, RouterModule } from '@angular/router'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +import { UserService } from '../../services/user/userService'; +import { Review } from '../../model/review'; +import { UserReviewCard } from '../user-review-card/user-review-card'; +import { ReviewService } from '../../services/review/review-service'; +import { MatMenuModule } from '@angular/material/menu'; +@Component({ + selector: 'app-my-reviews', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatFormFieldModule, + MatSelectModule, + MatIconModule, + MatButtonModule, + MatPaginatorModule, + MatProgressSpinnerModule, + UserReviewCard, + MatMenuModule + ], + templateUrl: './my-reviews.html', +}) +export class MyReviews implements OnInit { + private reviewService = inject(ReviewService); + private userService = inject(UserService); + private router = inject(Router); + + reviews = signal([]); + totalReviews = signal(0); + loading = signal(true); + + // Стан + pageIndex = signal(0); + pageSize = signal(10); + sort = signal('createdAt,desc'); + + ngOnInit() { + this.loadReviews(); + } + + loadReviews() { + const user = this.userService.userProfile(); + if(!user) { + this.loading.set(false); + return; + } + const userId = user.id; + this.loading.set(true); + this.reviewService.getReviewsByUserId( + userId!, + this.pageIndex(), + this.pageSize(), + this.sort() + ).subscribe({ + next: (page) => { + this.reviews.set(page.items); + this.totalReviews.set(page.total); + this.loading.set(false); + }, + error: (err) => { + console.error(err); + this.loading.set(false); + } + }); + } + + onSortChange(newSort: string) { + this.sort.set(newSort); + this.pageIndex.set(0); + this.loadReviews(); + } + + onPageChange(e: PageEvent) { + this.pageIndex.set(e.pageIndex); + this.pageSize.set(e.pageSize); + this.loadReviews(); + } + + handleEdit(review: Review) { + this.router.navigate(['/book-details', review.bookId, 'review']); + } + + handleDelete(reviewId: string) { + this.reviewService.deleteReview(reviewId).subscribe({ + next: () => { + this.reviews.update(list => list.filter(r => r.id !== reviewId)); + this.totalReviews.update(t => t - 1); + }, + error: (err) => console.error('Delete failed', err) + }); + } + + getSortLabel(value: string): string { + switch (value) { + case 'createdAt,desc': return 'Newest first'; + case 'createdAt,asc': return 'Oldest first'; + case 'rating,desc': return 'Highest rated'; + case 'rating,asc': return 'Lowest rated'; + default: return 'Sort by'; + } + } +} \ No newline at end of file diff --git a/book-bazaar/src/app/components/review-form/review-form.css b/book-bazaar/src/app/components/review-form/review-form.css new file mode 100644 index 0000000..e69de29 diff --git a/book-bazaar/src/app/components/review-form/review-form.html b/book-bazaar/src/app/components/review-form/review-form.html new file mode 100644 index 0000000..67d1962 --- /dev/null +++ b/book-bazaar/src/app/components/review-form/review-form.html @@ -0,0 +1,93 @@ +
+ + + + @if (book(); as bookData) { +
+ +
+
+
+ +
+

+ {{ bookData.title }} +

+

+ by {{ bookData.author?.name }} +

+
+
+ +
+
+

+ {{ existingReview() ? 'Edit your review' : 'Write a review' }} +

+

+ Share your thoughts with other readers. What did you like or dislike? +

+
+ +
+ +
+
+ + + {{ ratingLabel }} + +
+ +
+ @for (star of [1,2,3,4,5]; track star) { + + {{ star <= (form.value.rating || 0) ? 'star' : 'star_border' }} + + } +
+ @if (form.controls.rating.invalid && form.controls.rating.touched) { +

+ error + Please select a rating star to proceed. +

+ } +
+ +
+ + + + + {{form.value.text?.length || 0}}/1000 characters + + @if (form.controls.text.hasError('maxlength')) { + Review cannot exceed 1000 characters + } + +
+ +
+ + +
+
+
+ +
+ } +
\ No newline at end of file diff --git a/book-bazaar/src/app/components/review-form/review-form.spec.ts b/book-bazaar/src/app/components/review-form/review-form.spec.ts new file mode 100644 index 0000000..770ed07 --- /dev/null +++ b/book-bazaar/src/app/components/review-form/review-form.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReviewForm } from './review-form'; + +describe('ReviewForm', () => { + let component: ReviewForm; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReviewForm] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ReviewForm); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/book-bazaar/src/app/components/review-form/review-form.ts b/book-bazaar/src/app/components/review-form/review-form.ts new file mode 100644 index 0000000..e86dba8 --- /dev/null +++ b/book-bazaar/src/app/components/review-form/review-form.ts @@ -0,0 +1,124 @@ +import { Component, inject, OnInit, signal, computed } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +// 👇 Додаємо Tooltip +import { MatTooltipModule } from '@angular/material/tooltip'; +import { BookService } from '../../services/book/book-service'; +import { UserService } from '../../services/user/userService'; +import { Review } from '../../model/review'; +import { Book } from '../../model/book'; +import { NgClass } from '@angular/common'; +import { ReviewService } from '../../services/review/review-service'; + +@Component({ + selector: 'app-review-form', + standalone: true, + // 👇 Додаємо MatTooltipModule та NgClass + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatTooltipModule, NgClass], + templateUrl: './review-form.html', +}) +export class ReviewForm implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private fb = inject(FormBuilder); + private reviewService = inject(ReviewService); + private bookService = inject(BookService); + private userService = inject(UserService); + + bookId = signal(0); + book = signal(null); + existingReview = signal(null); + + form = this.fb.group({ + rating: [0, [Validators.required, Validators.min(1), Validators.max(5)]], + text: ['', [Validators.maxLength(1000)]] + }); + + // 👇 Допоміжний гетер для тексту оцінки + get ratingLabel(): string { + const rating = this.form.value.rating || 0; + switch (rating) { + case 1: return 'Terrible'; + case 2: return 'Bad'; + case 3: return 'Average'; + case 4: return 'Good'; + case 5: return 'Amazing!'; + default: return 'Select your rating'; + } + } + + // Тексти підказок для тултипів + ratingTooltips = ['Terrible', 'Bad', 'Average', 'Good', 'Amazing!']; + + ngOnInit() { + const id = this.route.snapshot.paramMap.get('bookId'); + if (id) { + this.bookId.set(Number(id)); + this.loadData(); + } + } + + async loadData() { + this.bookService.getBookById(this.bookId()).subscribe(b => this.book.set(b)); + + const user = this.userService.userProfile(); + if (user) { + const userId = user.id; + this.reviewService.getUserReview(this.bookId(), userId!).subscribe(review => { + if (review) { + this.existingReview.set(review); + this.form.patchValue({ + rating: review.rating, + text: review.text + }); + } + }); + } + } + + setRating(star: number) { + this.form.controls.rating.setValue(star); + this.form.controls.rating.markAsTouched(); + } + + submit() { + if (this.form.invalid) return; + + const request = { + bookId: this.bookId(), + rating: this.form.value.rating!, + text: this.form.value.text || '' + }; + + const review = this.existingReview(); + + const obs$ = review + ? this.reviewService.updateReview(review.id, request) + : this.reviewService.createReview(request); + + obs$.subscribe({ + next: () => this.router.navigate(['/book-details', this.bookId()]), + error: (err) => console.error('Failed to save review', err) + }); + } + + cancel() { + this.router.navigate(['/book-details', this.bookId()]); + } + + get hasUnsavedChanges(): boolean { + const initial = this.existingReview(); + const currentRating = this.form.value.rating ?? 0; + const currentText = this.form.value.text ?? ''; + + if (initial) { + return currentRating !== initial.rating || currentText.trim() !== (initial.text || '').trim(); + } else { + return currentRating !== 0 || currentText.trim().length > 0; + } + } +} \ No newline at end of file diff --git a/book-bazaar/src/app/components/user-review-card/user-review-card.css b/book-bazaar/src/app/components/user-review-card/user-review-card.css new file mode 100644 index 0000000..e69de29 diff --git a/book-bazaar/src/app/components/user-review-card/user-review-card.html b/book-bazaar/src/app/components/user-review-card/user-review-card.html new file mode 100644 index 0000000..62bf4e2 --- /dev/null +++ b/book-bazaar/src/app/components/user-review-card/user-review-card.html @@ -0,0 +1,67 @@ +
+ +
+ @if (loadingBook()) { +
+ } @else if (book(); as b) { + + + + } +
+ +
+ +
+
+ @if (book(); as b) { +

+ + {{ b.title }} + +

+

by {{ b.author?.name }}

+ } @else { +
+
+ } +
+ + + + + + +
+ +
+
+ @for (_ of [1,2,3,4,5]; track $index) { + + {{ $index < review().rating ? 'star' : 'star_border' }} + + } +
+ + {{ review().createdAt | date:'mediumDate' }} + +
+ +

+ {{ review().text }} +

+ + @if (!review().text) { +

No written review provided.

+ } + +
+
\ No newline at end of file diff --git a/book-bazaar/src/app/components/user-review-card/user-review-card.spec.ts b/book-bazaar/src/app/components/user-review-card/user-review-card.spec.ts new file mode 100644 index 0000000..9bcbec5 --- /dev/null +++ b/book-bazaar/src/app/components/user-review-card/user-review-card.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserReviewCard } from './user-review-card'; + +describe('UserReviewCard', () => { + let component: UserReviewCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserReviewCard] + }) + .compileComponents(); + + fixture = TestBed.createComponent(UserReviewCard); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/book-bazaar/src/app/components/user-review-card/user-review-card.ts b/book-bazaar/src/app/components/user-review-card/user-review-card.ts new file mode 100644 index 0000000..9159fdc --- /dev/null +++ b/book-bazaar/src/app/components/user-review-card/user-review-card.ts @@ -0,0 +1,57 @@ +import { Component, input, inject, OnInit, signal, output } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; +import { Review } from '../../model/review'; +import { Book } from '../../model/book'; +import { BookService } from '../../services/book/book-service'; + +@Component({ + selector: 'app-user-review-card', + standalone: true, + imports: [ + CommonModule, + RouterModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatMenuModule, + DatePipe + ], + templateUrl: './user-review-card.html' +}) +export class UserReviewCard implements OnInit { + review = input.required(); + + // Події для батьківського компонента + onDelete = output(); + onEdit = output(); + + private bookService = inject(BookService); + + book = signal(null); + loadingBook = signal(true); + + ngOnInit() { + this.bookService.getBookById(this.review().bookId).subscribe({ + next: (b) => { + this.book.set(b); + this.loadingBook.set(false); + }, + error: () => this.loadingBook.set(false) + }); + } + + delete() { + if (confirm('Are you sure you want to delete this review?')) { + this.onDelete.emit(this.review().id); + } + } + + edit() { + this.onEdit.emit(this.review()); + } +} \ No newline at end of file diff --git a/book-bazaar/src/app/model/review-metric.ts b/book-bazaar/src/app/model/review-metric.ts new file mode 100644 index 0000000..4071a55 --- /dev/null +++ b/book-bazaar/src/app/model/review-metric.ts @@ -0,0 +1,7 @@ +export interface ReviewMetrics { + id: string; + bookId: number; + totalReviews: number; + averageRating: number; + reviewCountsRating: Record; +} \ No newline at end of file diff --git a/book-bazaar/src/app/model/review-request.ts b/book-bazaar/src/app/model/review-request.ts new file mode 100644 index 0000000..3716ce0 --- /dev/null +++ b/book-bazaar/src/app/model/review-request.ts @@ -0,0 +1,5 @@ +export interface ReviewRequest { + bookId: number; + rating: number; + text?: string; +} \ No newline at end of file diff --git a/book-bazaar/src/app/model/review.ts b/book-bazaar/src/app/model/review.ts new file mode 100644 index 0000000..d562854 --- /dev/null +++ b/book-bazaar/src/app/model/review.ts @@ -0,0 +1,11 @@ +export interface Review { + id: string; + userId: string; + firstName?: string; + lastName?: string; + avatarUrl?: string; + bookId: number; + createdAt: string; + rating: number; + text: string; +} \ No newline at end of file diff --git a/book-bazaar/src/app/services/review/review-service.spec.ts b/book-bazaar/src/app/services/review/review-service.spec.ts new file mode 100644 index 0000000..0f44489 --- /dev/null +++ b/book-bazaar/src/app/services/review/review-service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed } from '@angular/core/testing'; +import { ReviewService } from './review-service'; + +describe('ReviewService', () => { + let service: ReviewService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ReviewService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/book-bazaar/src/app/services/review/review-service.ts b/book-bazaar/src/app/services/review/review-service.ts new file mode 100644 index 0000000..1d0fb2f --- /dev/null +++ b/book-bazaar/src/app/services/review/review-service.ts @@ -0,0 +1,83 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { map, Observable } from 'rxjs'; +import { PageResponse } from '../../model/pageResponse'; +import { Review } from '../../model/review'; +import { ReviewMetrics } from '../../model/review-metric'; +import { ReviewRequest } from '../../model/review-request'; + +@Injectable({ + providedIn: 'root' +}) +export class ReviewService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:9000/review-service/api'; + + getReviews( + bookId: number, + page: number = 0, + size: number = 10, + sort: string = 'createdAt,desc', + rating?: number + ): Observable> { + + const criteria = [`bookId=${bookId}`]; + + if (rating) { + criteria.push(`rating=${rating}`); + } + + let params = new HttpParams() + .set('pageIndex', page) + .set('pageSize', size) + .set('sort', sort) + .set('search', criteria.join(',')); + + return this.http.get>(`${this.apiUrl}/reviews`, { params }); + } + + getBookMetrics(bookId: number): Observable { + return this.http.get(`${this.apiUrl}/review-metrics/by-book/${bookId}`); + } + + createReview(request: ReviewRequest): Observable { + return this.http.post(`${this.apiUrl}/reviews`, request); + } + + updateReview(id: string, request: ReviewRequest): Observable { + return this.http.put(`${this.apiUrl}/reviews/${id}`, request); + } + + getUserReview(bookId: number, userId: string): Observable { + const params = new HttpParams() + .set('pageIndex', 0) + .set('pageSize', 1) + .set('search', `bookId=${bookId},userId=${userId}`); + + return this.http.get>(`${this.apiUrl}/reviews`, { params }).pipe( + map(page => page.items.length > 0 ? page.items[0] : null) + ); + } + + getReviewsByUserId( + userId: string, + page: number = 0, + size: number = 10, + sort: string = 'createdAt,desc' + ): Observable> { + + const search = `userId=${userId}`; + + let params = new HttpParams() + .set('pageIndex', page) + .set('pageSize', size) + .set('sort', sort) + .set('search', search); + + return this.http.get>(`${this.apiUrl}/reviews`, { params }); + } + + deleteReview(reviewId: string): Observable { + return this.http.delete(`${this.apiUrl}/reviews/${reviewId}`); + } +} \ No newline at end of file diff --git a/bookService/src/main/java/org/library/bookservice/config/OpenAPIConfig.java b/bookService/src/main/java/org/library/bookservice/config/OpenAPIConfig.java index 6c3a41b..992dc7e 100644 --- a/bookService/src/main/java/org/library/bookservice/config/OpenAPIConfig.java +++ b/bookService/src/main/java/org/library/bookservice/config/OpenAPIConfig.java @@ -21,42 +21,47 @@ public class OpenAPIConfig { @Value("${openapi.api-docs.token-uri}") private String keycloakTokenUrl; - private String passwordSecurityScheme = "passwordFlow"; + @Value("${openapi.api-docs.auth-uri}") + private String keycloakAuthCodeUrl; private String clientCredentialsSecurityScheme = "clientCredentialsFlow"; + private final String standardSecurityScheme = "standardFlow"; + @Bean public OpenAPI configureOpenAPI() { Server server = new Server().url("http://localhost:" + port); return new OpenAPI() - .servers(List.of(server)) - .info(new Info().title("Book API") - .description("API for Book Service") - .version("0.1") - .license(new License().name("Apache 2.0"))) - .components(new Components() - .addSecuritySchemes(passwordSecurityScheme, passwordFlowScheme()) - .addSecuritySchemes(clientCredentialsSecurityScheme, clientCredentialsScheme())) - .addSecurityItem(new SecurityRequirement() - .addList(passwordSecurityScheme) - .addList(clientCredentialsSecurityScheme)); + .servers(List.of(server)) + .info(new Info().title("Book API") + .description("API for Book Service") + .version("0.1") + .license(new License().name("Apache 2.0"))) + .components(new Components() + .addSecuritySchemes(standardSecurityScheme, standardFlowScheme()) + .addSecuritySchemes(clientCredentialsSecurityScheme, clientCredentialsScheme())) + .addSecurityItem(new SecurityRequirement() + .addList(standardSecurityScheme) + .addList(clientCredentialsSecurityScheme)); } - private SecurityScheme passwordFlowScheme() { + private SecurityScheme standardFlowScheme() { return new SecurityScheme() - .type(SecurityScheme.Type.OAUTH2) - .description("Resource Owner Password Flow") - .flows(new OAuthFlows() - .password(new OAuthFlow() - .tokenUrl(keycloakTokenUrl))); + .type(SecurityScheme.Type.OAUTH2) + .description("Keycloak Authorization Code Flow") + .flows(new OAuthFlows() + .authorizationCode(new OAuthFlow() + .authorizationUrl(keycloakAuthCodeUrl) + .tokenUrl(keycloakTokenUrl) + .scopes(new Scopes().addString("openid", "openid scope")))); } private SecurityScheme clientCredentialsScheme() { return new SecurityScheme() - .type(SecurityScheme.Type.OAUTH2) - .description("Client Credentials Flow") - .flows(new OAuthFlows() - .clientCredentials(new OAuthFlow() - .tokenUrl(keycloakTokenUrl))); + .type(SecurityScheme.Type.OAUTH2) + .description("Client Credentials Flow") + .flows(new OAuthFlows() + .clientCredentials(new OAuthFlow() + .tokenUrl(keycloakTokenUrl))); } } diff --git a/bookService/src/main/java/org/library/bookservice/controllers/AbstractController.java b/bookService/src/main/java/org/library/bookservice/controllers/AbstractController.java index 41d1cbc..b5906ea 100644 --- a/bookService/src/main/java/org/library/bookservice/controllers/AbstractController.java +++ b/bookService/src/main/java/org/library/bookservice/controllers/AbstractController.java @@ -36,7 +36,7 @@ import java.util.regex.Pattern; @Slf4j -@SecurityRequirement(name = "passwordFlow") +@SecurityRequirement(name = "standardFlow") @SecurityRequirement(name = "clientCredentialsFlow") public abstract class AbstractController { diff --git a/bookService/src/main/resources/application.properties b/bookService/src/main/resources/application.properties index 19cbb6f..f04f1ce 100644 --- a/bookService/src/main/resources/application.properties +++ b/bookService/src/main/resources/application.properties @@ -6,7 +6,7 @@ spring.jpa.hibernate.ddl-auto=none springdoc.api-docs.path=/api-docs openapi.api-docs.token-uri=http://localhost:8181/realms/e-library/protocol/openid-connect/token - +openapi.api-docs.auth-uri=http://localhost:8181/realms/e-library/protocol/openid-connect/auth spring.kafka.template.default-topic=book-deleted spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=io.confluent.kafka.serializers.KafkaAvroSerializer diff --git a/reviewService/src/main/java/org/library/reviewService/config/OpenAPIConfig.java b/reviewService/src/main/java/org/library/reviewService/config/OpenAPIConfig.java index 9d33419..7ff8b4c 100644 --- a/reviewService/src/main/java/org/library/reviewService/config/OpenAPIConfig.java +++ b/reviewService/src/main/java/org/library/reviewService/config/OpenAPIConfig.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.security.OAuthFlow; import io.swagger.v3.oas.models.security.OAuthFlows; +import io.swagger.v3.oas.models.security.Scopes; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; @@ -25,34 +26,38 @@ public class OpenAPIConfig { @Value("${openapi.api-docs.token-uri}") private String keycloakTokenUrl; - private String passwordSecurityScheme = "passwordFlow"; + @Value("${openapi.api-docs.auth-uri}") + private String keycloakAuthCodeUrl; - private String clientCredentialsSecurityScheme = "clientCredentialsFlow"; + private final String clientCredentialsSecurityScheme = "clientCredentialsFlow"; + private final String standardSecurityScheme = "standardFlow"; @Bean public OpenAPI configureOpenAPI() { Server server = new Server().url("http://localhost:" + port); return new OpenAPI() - .servers(List.of(server)) - .info(new Info().title("Review API") - .description("API for Review Service") - .version("0.1") - .license(new License().name("Apache 2.0"))) - .components(new Components() - .addSecuritySchemes(passwordSecurityScheme, passwordFlowScheme()) - .addSecuritySchemes(clientCredentialsSecurityScheme, clientCredentialsScheme())) - .addSecurityItem(new SecurityRequirement() - .addList(passwordSecurityScheme) - .addList(clientCredentialsSecurityScheme)); + .servers(List.of(server)) + .info(new Info().title("Review API") + .description("API for Review Service") + .version("0.1") + .license(new License().name("Apache 2.0"))) + .components(new Components() + .addSecuritySchemes(standardSecurityScheme, standardFlowScheme()) + .addSecuritySchemes(clientCredentialsSecurityScheme, clientCredentialsScheme())) + .addSecurityItem(new SecurityRequirement() + .addList(standardSecurityScheme) + .addList(clientCredentialsSecurityScheme)); } - private SecurityScheme passwordFlowScheme() { + private SecurityScheme standardFlowScheme() { return new SecurityScheme() - .type(SecurityScheme.Type.OAUTH2) - .description("Resource Owner Password Flow") - .flows(new OAuthFlows() - .password(new OAuthFlow() - .tokenUrl(keycloakTokenUrl))); + .type(SecurityScheme.Type.OAUTH2) + .description("Keycloak Authorization Code Flow") + .flows(new OAuthFlows() + .authorizationCode(new OAuthFlow() + .authorizationUrl(keycloakAuthCodeUrl) + .tokenUrl(keycloakTokenUrl) + .scopes(new Scopes().addString("openid", "openid scope")))); } private SecurityScheme clientCredentialsScheme() { diff --git a/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java b/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java index e11a471..efc10d7 100644 --- a/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java +++ b/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java @@ -37,7 +37,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); http - .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(authorize -> authorize .requestMatchers(freeResourceUrls).permitAll() .requestMatchers("/actuator/**").permitAll() @@ -67,15 +66,4 @@ public JwtAuthenticationConverter jwtAuthenticationConverter() { return converter; } - - @Bean - CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(gatewayUrl)); - configuration.setAllowedMethods(List.of("*")); - configuration.setAllowedHeaders(List.of("*")); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } } diff --git a/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java b/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java index d7ae4fa..da802d8 100644 --- a/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java +++ b/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java @@ -36,7 +36,7 @@ import java.util.regex.Pattern; @Slf4j -@SecurityRequirement(name = "passwordFlow") +@SecurityRequirement(name = "standardFlow") @SecurityRequirement(name = "clientCredentialsFlow") public abstract class AbstractController { @@ -71,7 +71,7 @@ public ResponseEntity> getAll( Page responseList = getService().getAll(query, PageRequest.of(pageIndex, pageSize, parsedSort)); return ResponseEntity.ok(PageResponse.builder() .size(responseList.getSize()) - .total(responseList.getTotalPages()) + .total(responseList.getTotalElements()) .pageNumber(responseList.getNumber()) .items(getMapper().entityToResponseList(responseList.getContent())) .build()); diff --git a/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java b/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java index bfb3c9e..5573da2 100644 --- a/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java +++ b/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java @@ -52,7 +52,7 @@ private Review createReview(String userId, Integer bookId, Integer rating, Strin review.setText(text); review.setArchived(false); - reviewMetricsService.updateMetrics(bookId, rating); + reviewMetricsService.addReviewMetrics(bookId, rating); return review; } diff --git a/reviewService/src/main/java/org/library/reviewService/dto/PageResponse.java b/reviewService/src/main/java/org/library/reviewService/dto/PageResponse.java index 66606c0..763134f 100644 --- a/reviewService/src/main/java/org/library/reviewService/dto/PageResponse.java +++ b/reviewService/src/main/java/org/library/reviewService/dto/PageResponse.java @@ -10,7 +10,7 @@ public class PageResponse { private int size; - private int total; + private long total; private int pageNumber; private List items; diff --git a/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewRequest.java b/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewRequest.java index 2b7c982..8445dcc 100644 --- a/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewRequest.java +++ b/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewRequest.java @@ -13,9 +13,6 @@ @Builder public class ReviewRequest extends AbstractRequest { - @NotNull - private String userId; - @NotNull private Integer bookId; diff --git a/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java b/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java index 80dde6e..29a5464 100644 --- a/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java +++ b/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java @@ -16,6 +16,12 @@ public class ReviewResponse extends AbstractResponse { private String userId; + private String firstName; + + private String lastName; + + private String avatarUrl; + private Integer bookId; private LocalDateTime createdAt; diff --git a/reviewService/src/main/java/org/library/reviewService/filter/model/review/ReviewSpecificationBuilder.java b/reviewService/src/main/java/org/library/reviewService/filter/model/review/ReviewSpecificationBuilder.java index def6cb6..84ecd6b 100644 --- a/reviewService/src/main/java/org/library/reviewService/filter/model/review/ReviewSpecificationBuilder.java +++ b/reviewService/src/main/java/org/library/reviewService/filter/model/review/ReviewSpecificationBuilder.java @@ -12,7 +12,7 @@ public class ReviewSpecificationBuilder implements DocumentFilterSpecificationBuilder { private final List filterableProperties = List.of( - new FilterableProperty("userId", Integer.class, new EqualingSpecificationBuilder(), + new FilterableProperty("userId", String.class, new EqualingSpecificationBuilder(), EqualingSpecificationBuilder.SUPPORTED_OPERATORS), new FilterableProperty("bookId", Integer.class, new EqualingSpecificationBuilder(), EqualingSpecificationBuilder.SUPPORTED_OPERATORS), diff --git a/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java b/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java index b5f2ac8..a2d9558 100644 --- a/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java +++ b/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java @@ -3,8 +3,12 @@ import lombok.AllArgsConstructor; import org.library.reviewService.dto.review.ReviewRequest; import org.library.reviewService.dto.review.ReviewResponse; +import org.library.reviewService.exception.AccessDeniedException; import org.library.reviewService.model.Review; import org.library.reviewService.repository.ReviewRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -15,6 +19,10 @@ @AllArgsConstructor public class ReviewMapper implements IMapper { + private static final String SUB = "sub"; + private static final String GIVEN_NAME = "given_name"; + private static final String FAMILY_NAME = "family_name"; + private static final String PICTURE = "picture"; private final ReviewRepository reviewRepository; @Override @@ -22,13 +30,20 @@ public Review requestToEntity(Optional id, ReviewRequest request) { Review entity = new Review(); entity.setId(id.orElse(null)); - entity.setUserId(request.getUserId()); entity.setBookId(request.getBookId()); if(id.isPresent()) { - entity.setCreatedAt(reviewRepository.findById(id.get()).orElseThrow(() -> new IllegalArgumentException("Unknown review id")).getCreatedAt()); + Review existing = reviewRepository.findById(id.get()) + .orElseThrow(() -> new IllegalArgumentException("Unknown review id")); + entity.setCreatedAt(existing.getCreatedAt()); + + entity.setUserId(existing.getUserId()); + entity.setFirstName(existing.getFirstName()); + entity.setLastName(existing.getLastName()); + entity.setAvatarUrl(existing.getAvatarUrl()); } else { entity.setCreatedAt(LocalDateTime.now()); + populateUserData(entity); } entity.setRating(request.getRating()); @@ -42,6 +57,9 @@ public ReviewResponse entityToResponse(Review entity) { return ReviewResponse.builder() .id(entity.getId()) .userId(entity.getUserId()) + .firstName(entity.getFirstName()) + .lastName(entity.getLastName()) + .avatarUrl(entity.getAvatarUrl()) .bookId(entity.getBookId()) .createdAt(entity.getCreatedAt()) .rating(entity.getRating()) @@ -53,4 +71,20 @@ public ReviewResponse entityToResponse(Review entity) { public List entityToResponseList(List entityList) { return entityList.stream().map(this::entityToResponse).toList(); } + + private void populateUserData(Review entity) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { + entity.setUserId(jwt.getClaimAsString(SUB)); + entity.setFirstName(jwt.getClaimAsString(GIVEN_NAME)); + entity.setLastName(jwt.getClaimAsString(FAMILY_NAME)); + + if (jwt.hasClaim(PICTURE)) { + entity.setAvatarUrl(jwt.getClaimAsString(PICTURE)); + } + } else { + throw new AccessDeniedException("User must be authenticated to create reviews"); + } + } } diff --git a/reviewService/src/main/java/org/library/reviewService/model/Review.java b/reviewService/src/main/java/org/library/reviewService/model/Review.java index 3da8ab1..7e92a9a 100644 --- a/reviewService/src/main/java/org/library/reviewService/model/Review.java +++ b/reviewService/src/main/java/org/library/reviewService/model/Review.java @@ -18,6 +18,12 @@ public class Review implements Identifiable, Archivable { private String userId; + private String firstName; + + private String lastName; + + private String avatarUrl; + private Integer bookId; private LocalDateTime createdAt; diff --git a/reviewService/src/main/java/org/library/reviewService/service/AbstractService.java b/reviewService/src/main/java/org/library/reviewService/service/AbstractService.java index 69af1a6..0e8ea16 100644 --- a/reviewService/src/main/java/org/library/reviewService/service/AbstractService.java +++ b/reviewService/src/main/java/org/library/reviewService/service/AbstractService.java @@ -34,11 +34,11 @@ public Page getAll(Query query, Pageable pageable) { public Page getAll(Query query, Pageable pageable, boolean includeArchived) { return PageableExecutionUtils.getPage(getMongoOperations() - .find(query.with(pageable) - .addCriteria(addArchivedCriteria(includeArchived)) - .addCriteria(addAdditionalCriteriaForGetAll()), getEntityClass()), - pageable, - () -> getMongoOperations().count(Query.of(query).limit(-1).skip(-1), getEntityClass())); + .find(query.with(pageable) + .addCriteria(addArchivedCriteria(includeArchived)) + .addCriteria(addAdditionalCriteriaForGetAll()), getEntityClass()), + pageable, + () -> getMongoOperations().count(Query.of(query).limit(-1).skip(-1), getEntityClass())); } protected Criteria addArchivedCriteria(boolean includeArchived) { @@ -54,7 +54,16 @@ public Optional getById(String id) { } public Optional getOne(Query query) { - return Optional.ofNullable(getMongoOperations().findOne(query, getEntityClass())); + return getOneDocumentByQuery(query, true); + } + + public Optional getOne(Query query, boolean includeArchived) { + return getOneDocumentByQuery(query, includeArchived); + } + + private Optional getOneDocumentByQuery(Query query, boolean includeArchived) { + return Optional.ofNullable(getMongoOperations() + .findOne(query.addCriteria(addArchivedCriteria(includeArchived)), getEntityClass())); } public DocumentType create(DocumentType entity) { @@ -102,6 +111,9 @@ public void delete(DocumentType entity, boolean ignorePermissions) { protected void beforeDelete(DocumentType entity) { } + protected void afterDelete(DocumentType entity) { + } + public void deleteById(String id) { DocumentType entity = getRepository().findById(id).orElseThrow(); @@ -113,6 +125,8 @@ public void deleteById(String id) { } else { deleteById(id, true); } + + afterDelete(entity); } public void deleteById(String id, boolean ignorePermissions) { diff --git a/reviewService/src/main/java/org/library/reviewService/service/ReviewMetricsService.java b/reviewService/src/main/java/org/library/reviewService/service/ReviewMetricsService.java index cb1b012..e10c36a 100644 --- a/reviewService/src/main/java/org/library/reviewService/service/ReviewMetricsService.java +++ b/reviewService/src/main/java/org/library/reviewService/service/ReviewMetricsService.java @@ -40,7 +40,7 @@ public Optional getByBookId(Integer bookId) { return getOne(new Query(Criteria.where("bookId").is(bookId))); } - public void updateMetrics(Integer bookId, Integer reviewRating) { + public void addReviewMetrics(Integer bookId, Integer reviewRating) { ReviewMetrics metrics = getByBookId(bookId).orElse(new ReviewMetrics()); if(metrics.getBookId() == null) { metrics.setBookId(bookId); @@ -55,6 +55,50 @@ public void updateMetrics(Integer bookId, Integer reviewRating) { update(metrics); } + public void updateMetricsAfterEdit(Integer bookId, Integer oldRating, Integer newRating) { + if (oldRating.equals(newRating)) return; + + ReviewMetrics metrics = getByBookId(bookId).orElseThrow( + () -> new IllegalStateException("Metrics not found for book " + bookId)); + + Map counts = metrics.getReviewCountsRating(); + + String oldKey = oldRating.toString(); + if (counts.containsKey(oldKey)) { + int currentCount = counts.get(oldKey); + if (currentCount > 0) { + counts.put(oldKey, currentCount - 1); + } + } + + String newKey = newRating.toString(); + counts.putIfAbsent(newKey, 0); + counts.put(newKey, counts.get(newKey) + 1); + + metrics.setAverageRating(calculateAverageRating(metrics)); + + update(metrics); + } + + public void removeReviewMetrics(Integer bookId, Integer rating) { + getByBookId(bookId).ifPresent(metrics -> { + if (metrics.getTotalReviews() > 0) { + metrics.setTotalReviews(metrics.getTotalReviews() - 1); + } + + String key = rating.toString(); + if (metrics.getReviewCountsRating().containsKey(key)) { + int count = metrics.getReviewCountsRating().get(key); + if (count > 0) { + metrics.getReviewCountsRating().put(key, count - 1); + } + } + + metrics.setAverageRating(calculateAverageRating(metrics)); + update(metrics); + }); + } + private Double calculateAverageRating(ReviewMetrics metrics) { double ratingsSum = 0; for(Map.Entry pair : metrics.getReviewCountsRating().entrySet()) { diff --git a/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java b/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java index 24c5fab..f479c4a 100644 --- a/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java +++ b/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java @@ -6,13 +6,17 @@ import org.library.reviewService.model.Review; import org.library.reviewService.repository.BaseRepository; import org.library.reviewService.repository.ReviewRepository; +import org.springframework.dao.DuplicateKeyException; import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; import java.util.NoSuchElementException; +import java.util.Optional; @Service @AllArgsConstructor @@ -45,6 +49,15 @@ protected void beforeCreate(Review entity) { } catch (NoSuchElementException e) { throw new IllegalArgumentException("No book was found with id " + entity.getBookId()); } + + Query query = new Query(Criteria.where("userId").is(entity.getUserId()) + .and("bookId").is(entity.getBookId())); + + Optional optionalReview = getOne(query, false); + + if (optionalReview.isPresent()) { + throw new DuplicateKeyException("User has already reviewed this book. Use update instead."); + } } @Override @@ -66,15 +79,35 @@ private void checkAuthority(Review entity) { boolean isAdmin = authorities.stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); boolean isUserOwnerOfReview = entity.getUserId().equals(subject); - if(isAdmin || isUserOwnerOfReview) { + if (isAdmin || isUserOwnerOfReview) { return; } throw new AccessDeniedException("User has no rights to access this resource"); } + @Override + public Review update(Review entity) { + Review oldReview = reviewRepository.findById(entity.getId()) + .orElseThrow(() -> new NoSuchElementException("Review not found")); + + Integer oldRating = oldReview.getRating(); + Integer newRating = entity.getRating(); + + Review saved = super.update(entity); + + metricsService.updateMetricsAfterEdit(saved.getBookId(), oldRating, newRating); + + return saved; + } + @Override protected void afterCreate(Review entity) { - metricsService.updateMetrics(entity.getBookId(), entity.getRating()); + metricsService.addReviewMetrics(entity.getBookId(), entity.getRating()); + } + + @Override + protected void afterDelete(Review entity) { + metricsService.removeReviewMetrics(entity.getBookId(), entity.getRating()); } } diff --git a/reviewService/src/main/java/org/library/reviewService/utils/GlobalExceptionHandler.java b/reviewService/src/main/java/org/library/reviewService/utils/GlobalExceptionHandler.java index eab3c65..43b6d73 100644 --- a/reviewService/src/main/java/org/library/reviewService/utils/GlobalExceptionHandler.java +++ b/reviewService/src/main/java/org/library/reviewService/utils/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import org.library.reviewService.exception.AccessDeniedException; import org.springdoc.api.ErrorMessage; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -80,6 +81,15 @@ public ResponseEntity handleUnsupportedOperationException( return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(new ErrorMessage(message)); } + @ExceptionHandler(DuplicateKeyException.class) + public ResponseEntity handleDuplicateKeyException( + DuplicateKeyException ex) { + log.error("Conflict! Duplicated key: ", ex); + + String message = "Conflict! Duplicated key:" + ex.getMessage(); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorMessage(message)); + } @ExceptionHandler({Exception.class}) public ResponseEntity handleAllExceptions(Exception ex) { diff --git a/reviewService/src/main/resources/application-dev.yml b/reviewService/src/main/resources/application-dev.yml index ac3074e..f1bdb31 100644 --- a/reviewService/src/main/resources/application-dev.yml +++ b/reviewService/src/main/resources/application-dev.yml @@ -8,7 +8,7 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: http://keycloak:8080/realms/e-library + issuer-uri: http://localhost:8181/realms/e-library jwk-set-uri: http://localhost:8181/realms/e-library/protocol/openid-connect/certs token-uri: http://localhost:8181/realms/e-library/protocol/openid-connect/token diff --git a/reviewService/src/main/resources/application.properties b/reviewService/src/main/resources/application.properties index ea47240..59f280c 100644 --- a/reviewService/src/main/resources/application.properties +++ b/reviewService/src/main/resources/application.properties @@ -1,4 +1,4 @@ -spring.profiles.active="@spring.profiles.active@" +spring.profiles.active=@spring.profiles.active@ spring.application.name=reviewService server.port=8081 @@ -8,6 +8,7 @@ logging.level.org.springframework.security=DEBUG springdoc.api-docs.path=/api-docs openapi.api-docs.token-uri=http://localhost:8181/realms/e-library/protocol/openid-connect/token +openapi.api-docs.auth-uri=http://localhost:8181/realms/e-library/protocol/openid-connect/auth spring.kafka.consumer.group-id=review-service spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer