From 7b4433a0508f895b4920597debc89e3d18c63730 Mon Sep 17 00:00:00 2001 From: Maksym Diachuk Date: Tue, 2 Dec 2025 01:01:17 +0200 Subject: [PATCH 1/2] Implemented reviews in book details. Integrated with review-service --- book-bazaar/public/author-placeholder.jpg | Bin 0 -> 13200 bytes book-bazaar/src/app/app.config.ts | 10 +- book-bazaar/src/app/app.ts | 41 ++- .../src/app/components/book-card/book-card.ts | 42 +-- .../components/book-details/book-details.html | 259 ++++++++++++++---- .../components/book-details/book-details.ts | 128 ++++++++- book-bazaar/src/app/model/review-metric.ts | 7 + book-bazaar/src/app/model/review.ts | 11 + .../services/review/review-service.spec.ts | 15 + .../src/app/services/review/review-service.ts | 41 +++ .../bookservice/config/OpenAPIConfig.java | 51 ++-- .../controllers/AbstractController.java | 2 +- .../src/main/resources/application.properties | 2 +- .../reviewService/config/OpenAPIConfig.java | 43 +-- .../reviewService/config/SecurityConfig.java | 12 - .../controller/AbstractController.java | 2 +- .../dto/review/ReviewRequest.java | 3 - .../dto/review/ReviewResponse.java | 6 + .../reviewService/mapper/ReviewMapper.java | 38 ++- .../library/reviewService/model/Review.java | 6 + .../src/main/resources/application-dev.yml | 2 +- .../src/main/resources/application.properties | 3 +- 22 files changed, 570 insertions(+), 154 deletions(-) create mode 100644 book-bazaar/public/author-placeholder.jpg create mode 100644 book-bazaar/src/app/model/review-metric.ts create mode 100644 book-bazaar/src/app/model/review.ts create mode 100644 book-bazaar/src/app/services/review/review-service.spec.ts create mode 100644 book-bazaar/src/app/services/review/review-service.ts diff --git a/book-bazaar/public/author-placeholder.jpg b/book-bazaar/public/author-placeholder.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4dc0d100013b33ac524d9cc78864c97aed7c1f58 GIT binary patch literal 13200 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 I>2%_M0r%}N#Q*>R literal 0 HcmV?d00001 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.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..83b15cd 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,56 +87,197 @@

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) }}) + + + } +
+
+ +
+

What do you think?

+

Share your opinion with the community

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

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

+ + + + Newest first + Oldest first + Highest rated + Lowest rated + +
+ @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..5d7097f 100644 --- a/book-bazaar/src/app/components/book-details/book-details.ts +++ b/book-bazaar/src/app/components/book-details/book-details.ts @@ -7,7 +7,14 @@ 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' @Component({ selector: 'app-book-details', @@ -18,31 +25,46 @@ import { DatePipe, CurrencyPipe } from '@angular/common'; MatChipsModule, MatDividerModule, MatProgressSpinnerModule, - DatePipe, - CurrencyPipe - ], + DatePipe, + CurrencyPipe, + MatPaginator, + MatFormField, + MatOption, + MatSelectModule, + DecimalPipe, + NgClass +], templateUrl: './book-details.html', styleUrl: './book-details.css', }) export class BookDetails implements OnInit { private route = inject(ActivatedRoute); private bookService = inject(BookService); - + private reviewService = inject(ReviewService); + book = signal(null); loading = signal(true); - // Для відображення рейтингу (поки що заглушка, пізніше підтягнемо з бекенду) - rating = 4.4; - totalRatings = 1250; + 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); } } @@ -59,12 +81,93 @@ 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); + } + }); + } + + 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) { this.userRating.set(star); console.log(`User rated: ${star}`); - // Тут буде виклик методу для збереження рейтингу } setHoverRating(star: number) { @@ -77,6 +180,5 @@ export class BookDetails implements OnInit { writeReview() { console.log('Open review dialog'); - // Тут відкриємо діалог написання рецензії } } \ 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.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..805cd24 --- /dev/null +++ b/book-bazaar/src/app/services/review/review-service.ts @@ -0,0 +1,41 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { PageResponse } from '../../model/pageResponse'; +import { Review } from '../../model/review'; +import { ReviewMetrics } from '../../model/review-metric'; + +@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}`); + } +} \ 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..8d14aa3 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 { 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/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/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 From 10336e2606d5d267eebce8accc66703f46366b6f Mon Sep 17 00:00:00 2001 From: Maksym Diachuk Date: Fri, 5 Dec 2025 21:42:11 +0200 Subject: [PATCH 2/2] Implement reviews on frontend. Implement my-reviews component --- book-bazaar/src/app/app.routes.ts | 14 +- .../components/book-details/book-details.html | 49 +++++-- .../components/book-details/book-details.ts | 102 +++++++++++--- .../src/app/components/header/header.html | 63 +++++---- .../app/components/my-reviews/my-reviews.css | 0 .../app/components/my-reviews/my-reviews.html | 73 +++++++++++ .../components/my-reviews/my-reviews.spec.ts | 23 ++++ .../app/components/my-reviews/my-reviews.ts | 112 ++++++++++++++++ .../components/review-form/review-form.css | 0 .../components/review-form/review-form.html | 93 +++++++++++++ .../review-form/review-form.spec.ts | 23 ++++ .../app/components/review-form/review-form.ts | 124 ++++++++++++++++++ .../user-review-card/user-review-card.css | 0 .../user-review-card/user-review-card.html | 67 ++++++++++ .../user-review-card/user-review-card.spec.ts | 23 ++++ .../user-review-card/user-review-card.ts | 57 ++++++++ book-bazaar/src/app/model/review-request.ts | 5 + .../src/app/services/review/review-service.ts | 44 ++++++- .../controller/AbstractController.java | 2 +- .../datagen/InitialDataGenerator.java | 2 +- .../reviewService/dto/PageResponse.java | 2 +- .../review/ReviewSpecificationBuilder.java | 2 +- .../service/AbstractService.java | 26 +++- .../service/ReviewMetricsService.java | 46 ++++++- .../reviewService/service/ReviewService.java | 37 +++++- .../utils/GlobalExceptionHandler.java | 10 ++ 26 files changed, 926 insertions(+), 73 deletions(-) create mode 100644 book-bazaar/src/app/components/my-reviews/my-reviews.css create mode 100644 book-bazaar/src/app/components/my-reviews/my-reviews.html create mode 100644 book-bazaar/src/app/components/my-reviews/my-reviews.spec.ts create mode 100644 book-bazaar/src/app/components/my-reviews/my-reviews.ts create mode 100644 book-bazaar/src/app/components/review-form/review-form.css create mode 100644 book-bazaar/src/app/components/review-form/review-form.html create mode 100644 book-bazaar/src/app/components/review-form/review-form.spec.ts create mode 100644 book-bazaar/src/app/components/review-form/review-form.ts create mode 100644 book-bazaar/src/app/components/user-review-card/user-review-card.css create mode 100644 book-bazaar/src/app/components/user-review-card/user-review-card.html create mode 100644 book-bazaar/src/app/components/user-review-card/user-review-card.spec.ts create mode 100644 book-bazaar/src/app/components/user-review-card/user-review-card.ts create mode 100644 book-bazaar/src/app/model/review-request.ts 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/components/book-details/book-details.html b/book-bazaar/src/app/components/book-details/book-details.html index 83b15cd..ca151f0 100644 --- a/book-bazaar/src/app/components/book-details/book-details.html +++ b/book-bazaar/src/app/components/book-details/book-details.html @@ -49,7 +49,7 @@

• - + {{ metrics()?.totalReviews || 0 }} ratings

@@ -109,7 +109,7 @@

Book Details -
+
@@ -160,8 +160,12 @@

Community Rev

-

What do you think?

-

Share your opinion with the community

+

+ {{ 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) { @@ -178,7 +182,7 @@

What do you think?

@@ -198,14 +202,33 @@

}

- - - Newest first - Oldest first - Highest rated - Lowest rated - - +
+ + + + + + + + +
@if (reviewsLoading()) { 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 5d7097f..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'; @@ -13,8 +13,11 @@ 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 { 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', @@ -32,19 +35,24 @@ import { DatePipe, CurrencyPipe, NgClass, DecimalPipe } from '@angular/common' MatOption, MatSelectModule, DecimalPipe, - NgClass -], + 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); + userService = inject(UserService); // Інжект юзера + currentUserReview = signal(null); + reviews = signal([]); metrics = signal(null); reviewsTotal = signal(0); @@ -65,6 +73,7 @@ export class BookDetails implements OnInit { this.loadBook(bookId); this.loadMetrics(bookId); this.loadReviews(bookId); + this.checkUserReview(bookId); } } @@ -81,7 +90,7 @@ export class BookDetails implements OnInit { } }); } - + private loadMetrics(id: number) { this.reviewService.getBookMetrics(id).subscribe({ next: (data) => this.metrics.set(data), @@ -92,9 +101,9 @@ export class BookDetails implements OnInit { loadReviews(bookId: number) { this.reviewsLoading.set(true); this.reviewService.getReviews( - bookId, - this.pageIndex(), - this.pageSize(), + bookId, + this.pageIndex(), + this.pageSize(), this.currentSort(), this.selectedRatingFilter() ).subscribe({ @@ -110,6 +119,23 @@ export class BookDetails implements OnInit { }); } + 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); @@ -133,7 +159,7 @@ export class BookDetails implements OnInit { } else { this.selectedRatingFilter.set(star); } - + this.pageIndex.set(0); if (this.book()) { this.loadReviews(this.book()!.id!); @@ -143,7 +169,7 @@ export class BookDetails implements OnInit { 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; } @@ -153,7 +179,7 @@ export class BookDetails implements OnInit { const rounded = Math.round(rating * 2) / 2; if (rounded >= index + 1) { - return 'star'; + return 'star'; } else if (rounded >= index + 0.5) { return 'star_half'; } else { @@ -166,8 +192,41 @@ export class BookDetails implements OnInit { } 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) { @@ -178,7 +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-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/services/review/review-service.ts b/book-bazaar/src/app/services/review/review-service.ts index 805cd24..1d0fb2f 100644 --- a/book-bazaar/src/app/services/review/review-service.ts +++ b/book-bazaar/src/app/services/review/review-service.ts @@ -1,9 +1,10 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +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' @@ -38,4 +39,45 @@ export class ReviewService { 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/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java b/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java index 8d14aa3..da802d8 100644 --- a/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java +++ b/reviewService/src/main/java/org/library/reviewService/controller/AbstractController.java @@ -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/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/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) {