diff --git a/src/core/common/domain/value-objects/description.ts b/src/core/common/domain/value-objects/description.ts new file mode 100644 index 0000000..7a28aff --- /dev/null +++ b/src/core/common/domain/value-objects/description.ts @@ -0,0 +1,13 @@ +import { DomainError } from '@/core/common/domain/errors/domain-error' + +export class Description { + constructor(public readonly value: string) {} + + public static create(author: string): Description { + if (!author || author.length < 3) { + throw DomainError.cause('La descripción es demasiado corta') + } + + return new Description(author) + } +} diff --git a/src/core/common/domain/value-objects/review-id.ts b/src/core/common/domain/value-objects/review-id.ts new file mode 100644 index 0000000..3e41ac8 --- /dev/null +++ b/src/core/common/domain/value-objects/review-id.ts @@ -0,0 +1,3 @@ +import { Id } from '@/core/common/domain/value-objects/id' + +export class ReviewId extends Id {} diff --git a/src/core/common/domain/value-objects/score.ts b/src/core/common/domain/value-objects/score.ts new file mode 100644 index 0000000..75a2d83 --- /dev/null +++ b/src/core/common/domain/value-objects/score.ts @@ -0,0 +1,17 @@ +import { DomainError } from '@/core/common/domain/errors/domain-error' + +export class Score { + constructor(public readonly value: number) {} + + public static create(value: number): Score { + if (!Number.isInteger(value)) { + throw DomainError.cause('La puntuación debe ser un entero') + } + + if (value < 1 || value > 5) { + throw DomainError.cause('La puntuación debe ser un entero entre 1 y 5') + } + + return new Score(value) + } +} diff --git a/src/core/review/application/create-review.use-case.ts b/src/core/review/application/create-review.use-case.ts new file mode 100644 index 0000000..5687105 --- /dev/null +++ b/src/core/review/application/create-review.use-case.ts @@ -0,0 +1,12 @@ +import { ReviewFactory } from '@/core/review/domain/model/review.factory' +import { Reviews } from '@/core/review/domain/services/reviews.repository' +import { CreateReviewRequest } from '@/core/review/dto/requests/create-review.request' + +export class CreateReviewUseCase { + constructor(private readonly reviews: Reviews) {} + + async with(command: CreateReviewRequest) { + const review = ReviewFactory.create(command) + return this.reviews.save(review) + } +} diff --git a/src/core/review/domain/model/review.entity.ts b/src/core/review/domain/model/review.entity.ts new file mode 100644 index 0000000..d745994 --- /dev/null +++ b/src/core/review/domain/model/review.entity.ts @@ -0,0 +1,44 @@ +import { AggregateRoot } from '@/core/common/domain/model/aggregate-root' +import { BookId } from '@/core/common/domain/value-objects/book-id' +import { Description } from '@/core/common/domain/value-objects/description' +import { ReviewId } from '@/core/common/domain/value-objects/review-id' +import { Score } from '@/core/common/domain/value-objects/score' +import { Title } from '@/core/common/domain/value-objects/title' +import { UserId } from '@/core/common/domain/value-objects/user-id' + +export class Review extends AggregateRoot { + constructor( + protected _id: ReviewId, + protected _bookId: BookId, + protected _userId: UserId, + protected _title: Title, + protected _description: Description, + protected _score: Score, + ) { + super() + } + + get id(): ReviewId { + return this._id + } + + get bookId(): BookId { + return this._bookId + } + + get userId(): UserId { + return this._userId + } + + get title(): Title { + return this._title + } + + get description(): Description { + return this._description + } + + get score(): Score { + return this._score + } +} diff --git a/src/core/review/domain/model/review.factory.ts b/src/core/review/domain/model/review.factory.ts new file mode 100644 index 0000000..8f30c54 --- /dev/null +++ b/src/core/review/domain/model/review.factory.ts @@ -0,0 +1,21 @@ +import { BookId } from '@/core/common/domain/value-objects/book-id' +import { Description } from '@/core/common/domain/value-objects/description' +import { ReviewId } from '@/core/common/domain/value-objects/review-id' +import { Score } from '@/core/common/domain/value-objects/score' +import { Title } from '@/core/common/domain/value-objects/title' +import { UserId } from '@/core/common/domain/value-objects/user-id' +import { Review } from '@/core/review/domain/model/review.entity' +import { CreateReviewRequest } from '@/core/review/dto/requests/create-review.request' + +export const ReviewFactory = { + create: (reviewResponse: CreateReviewRequest): Review => { + const reviewId = ReviewId.create(reviewResponse.id) + const bookId = BookId.create(reviewResponse.bookId) + const userId = UserId.create(reviewResponse.userId) + const title = Title.create(reviewResponse.title) + const description = Description.create(reviewResponse.description) + const score = Score.create(reviewResponse.score) + + return new Review(reviewId, bookId, userId, title, description, score) + }, +} diff --git a/src/core/review/domain/services/reviews.repository.ts b/src/core/review/domain/services/reviews.repository.ts new file mode 100644 index 0000000..1eb1b0c --- /dev/null +++ b/src/core/review/domain/services/reviews.repository.ts @@ -0,0 +1,5 @@ +import { Review } from '@/core/review/domain/model/review.entity' + +export interface Reviews { + save(review: Review): Promise +} diff --git a/src/core/review/dto/requests/create-review.request.ts b/src/core/review/dto/requests/create-review.request.ts new file mode 100644 index 0000000..208052f --- /dev/null +++ b/src/core/review/dto/requests/create-review.request.ts @@ -0,0 +1,14 @@ +type CreateReviewRequest = { + bookId: string + description: string + id: string + score: number + title: string + userId: string +} + +const CreateReviewRequest = { + with: (properties: CreateReviewRequest): CreateReviewRequest => properties, +} + +export { CreateReviewRequest } diff --git a/src/core/review/infrastructure/actions/create-review.ts b/src/core/review/infrastructure/actions/create-review.ts index ef1b306..f0060aa 100644 --- a/src/core/review/infrastructure/actions/create-review.ts +++ b/src/core/review/infrastructure/actions/create-review.ts @@ -1,6 +1,12 @@ 'use server' +import { revalidateTag } from 'next/cache' +import { ulid } from 'ulid' +import { z } from 'zod' + +import { CreateReviewRequest } from '@/core/review/dto/requests/create-review.request' import { me } from '@/core/user/infrastructure/actions/me' +import { container } from '@/lib/container' import { FormResponse } from '@/lib/zod/form-response' export interface CreateReviewForm { @@ -11,8 +17,17 @@ export interface CreateReviewForm { title: string } +const CreateReviewSchema = z.object({ + bookId: z.string(), + description: z.string().min(3, 'El contenido es demasiado corto'), + id: z.string(), + score: z.string(), + title: z.string().min(3, 'El título es demasiado corto'), +}) + export async function createReview( previousState: FormResponse, + formData: FormData, ): Promise> { const { id: userId } = (await me()) || {} @@ -24,5 +39,32 @@ export async function createReview( ) } - return FormResponse.custom(['general'], 'No implementado', previousState.data) + const id = ulid() + const result = CreateReviewSchema.safeParse(Object.fromEntries(formData)) + + console.debug('createReview', result) + + if (!result.success) { + return FormResponse.withError(result.error, previousState.data) + } + + await container.createReview.with( + CreateReviewRequest.with({ + ...result.data, + id, + score: +result.data.score, + userId, + }), + ) + + revalidateTag('books') + revalidateTag(`book-${previousState.data.bookId}`) + + return FormResponse.success( + { + ...result.data, + id, + }, + 'Gracias por tu reseña.', + ) } diff --git a/src/core/review/infrastructure/persistence/review.data-mapper.ts b/src/core/review/infrastructure/persistence/review.data-mapper.ts new file mode 100644 index 0000000..171518d --- /dev/null +++ b/src/core/review/infrastructure/persistence/review.data-mapper.ts @@ -0,0 +1,14 @@ +import { Review } from '@/core/review/domain/model/review.entity' + +const ReviewDataMapper = { + toPrisma: (review: Review) => ({ + bookId: review.bookId.value, + description: review.description.value, + id: review.id.value, + score: review.score.value, + title: review.title.value, + userId: review.userId.value, + }), +} + +export { ReviewDataMapper } diff --git a/src/core/review/infrastructure/persistence/review.publisher.ts b/src/core/review/infrastructure/persistence/review.publisher.ts new file mode 100644 index 0000000..1731f73 --- /dev/null +++ b/src/core/review/infrastructure/persistence/review.publisher.ts @@ -0,0 +1,38 @@ +import { PrismaClient } from '@prisma/client' + +import { ApplicationError } from '@/core/common/domain/errors/application-error' +import { Publisher } from '@/core/common/domain/publisher/publisher' +import { Review } from '@/core/review/domain/model/review.entity' +import { ReviewDataMapper } from '@/core/review/infrastructure/persistence/review.data-mapper' + +export class ReviewPublisher extends Publisher { + constructor(private readonly prisma: PrismaClient) { + super() + } + + async create(review: Review): Promise { + const data = ReviewDataMapper.toPrisma(review) + + try { + await this.prisma.review.create({ data }) + } catch (error) { + throw new ApplicationError((error as Error).toString()) + } + } + + protected async update(review: Review, version: number): Promise { + const { id, ...data } = ReviewDataMapper.toPrisma(review) + + try { + await this.prisma.review.update({ + data, + where: { + id, + version, + }, + }) + } catch (error) { + throw new ApplicationError((error as Error).toString()) + } + } +} diff --git a/src/core/review/infrastructure/services/reviews-prisma.repository.ts b/src/core/review/infrastructure/services/reviews-prisma.repository.ts new file mode 100644 index 0000000..594b207 --- /dev/null +++ b/src/core/review/infrastructure/services/reviews-prisma.repository.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from '@prisma/client' + +import { Review } from '@/core/review/domain/model/review.entity' +import { Reviews } from '@/core/review/domain/services/reviews.repository' +import { ReviewPublisher } from '@/core/review/infrastructure/persistence/review.publisher' + +export class ReviewsPrisma implements Reviews { + private publisher: ReviewPublisher + + constructor(private readonly prisma: PrismaClient) { + this.publisher = new ReviewPublisher(prisma) + } + + async save(review: Review): Promise { + return this.publisher.mergeObjectContext(review).commit() + } +} diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index 685ca5f..d2f3ee2 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -9,8 +9,10 @@ import { LoanBookService } from '@/core/loan/domain/services/loan-book.service' import { ReturnBookService } from '@/core/loan/domain/services/return-book.service' import { GetHistoricalLoansQuery } from '@/core/loan/infrastructure/queries/get-historical-loans.query' import { LoansPrisma } from '@/core/loan/infrastructure/services/loans-prisma.repository' +import { CreateReviewUseCase } from '@/core/review/application/create-review.use-case' import { GetReviewStatsQuery } from '@/core/review/infrastructure/queries/get-review-stats.query' import { GetReviewsQuery } from '@/core/review/infrastructure/queries/get-reviews.query' +import { ReviewsPrisma } from '@/core/review/infrastructure/services/reviews-prisma.repository' import { EnableUserUseCase } from '@/core/user/application/enable-user.use-case' import { FindUserUseCase } from '@/core/user/application/find-user.use-case' import { UpdateSettingUseCase } from '@/core/user/application/update-setting.use-case' @@ -25,10 +27,12 @@ const Container = { const books = new BooksPrisma(prisma) const loans = new LoansPrisma(prisma) const loanBookService = new LoanBookService(loans) + const reviews = new ReviewsPrisma(prisma) const returnBookService = new ReturnBookService(loans) return { createBook: new CreateBookUseCase(books), + createReview: new CreateReviewUseCase(reviews), editBook: new EditBookUseCase(books), enableUser: new EnableUserUseCase(users), findBook: new FindBookQuery(prisma),