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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import com.google.protobuf.gradle.*

plugins {
java
jacoco
id("org.springframework.boot") version "4.0.1"
id("io.spring.dependency-management") version "1.1.7"
id("com.google.protobuf") version "0.9.4"
}

group = "com.devoops"
Expand All @@ -19,6 +22,8 @@ repositories {
mavenCentral()
}

val grpcVersion = "1.68.0"

dependencies {
// Web and Core
implementation("org.springframework.boot:spring-boot-starter-webmvc")
Expand Down Expand Up @@ -47,6 +52,13 @@ dependencies {
// MinIO S3-compatible object storage
implementation("io.minio:minio:8.5.7")

// gRPC Client
implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE")
implementation("io.grpc:grpc-protobuf:$grpcVersion")
implementation("io.grpc:grpc-stub:$grpcVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
compileOnly("javax.annotation:javax.annotation-api:1.3.2")

// Tracing (Zipkin)
implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave")
implementation("org.springframework.boot:spring-boot-starter-zipkin")
Expand All @@ -69,6 +81,24 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.5"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
id("grpc")
}
}
}
}

tasks.withType<Test> {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
Expand Down
4 changes: 4 additions & 0 deletions environment/.local.env
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ MINIO_ENDPOINT=http://devoops-minio:9000
MINIO_ACCESS_KEY=devoops
MINIO_SECRET_KEY=devoops123
MINIO_BUCKET=accommodation-photos

# gRPC
RESERVATION_GRPC_HOST=devoops-reservation-service
RESERVATION_GRPC_PORT=9090
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.devoops.accommodation.controller;

import com.devoops.accommodation.config.RequireRole;
import com.devoops.accommodation.config.UserContext;
import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest;
import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest;
import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse;
import com.devoops.accommodation.service.AvailabilityPeriodService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping("/api/accommodation/{accommodationId}/availability")
@RequiredArgsConstructor
public class AvailabilityPeriodController {

private final AvailabilityPeriodService availabilityPeriodService;

@PostMapping
@RequireRole("HOST")
public ResponseEntity<AvailabilityPeriodResponse> create(
@PathVariable UUID accommodationId,
@Valid @RequestBody CreateAvailabilityPeriodRequest request,
UserContext userContext) {
AvailabilityPeriodResponse response = availabilityPeriodService.create(accommodationId, request, userContext);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@GetMapping
public ResponseEntity<List<AvailabilityPeriodResponse>> getByAccommodationId(
@PathVariable UUID accommodationId) {
return ResponseEntity.ok(availabilityPeriodService.getByAccommodationId(accommodationId));
}

@GetMapping("/{periodId}")
public ResponseEntity<AvailabilityPeriodResponse> getById(
@PathVariable UUID accommodationId,
@PathVariable UUID periodId) {
return ResponseEntity.ok(availabilityPeriodService.getById(accommodationId, periodId));
}

@PutMapping("/{periodId}")
@RequireRole("HOST")
public ResponseEntity<AvailabilityPeriodResponse> update(
@PathVariable UUID accommodationId,
@PathVariable UUID periodId,
@Valid @RequestBody UpdateAvailabilityPeriodRequest request,
UserContext userContext) {
return ResponseEntity.ok(availabilityPeriodService.update(accommodationId, periodId, request, userContext));
}

@DeleteMapping("/{periodId}")
@RequireRole("HOST")
public ResponseEntity<Void> delete(
@PathVariable UUID accommodationId,
@PathVariable UUID periodId,
UserContext userContext) {
availabilityPeriodService.delete(accommodationId, periodId, userContext);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.devoops.accommodation.dto.request;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

import java.math.BigDecimal;
import java.time.LocalDate;

public record CreateAvailabilityPeriodRequest(
@NotNull(message = "Start date is required") LocalDate startDate,
@NotNull(message = "End date is required") LocalDate endDate,
@NotNull(message = "Price per day is required") @Positive(message = "Price per day must be positive") BigDecimal pricePerDay
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devoops.accommodation.dto.request;

import jakarta.validation.constraints.Positive;

import java.math.BigDecimal;
import java.time.LocalDate;

public record UpdateAvailabilityPeriodRequest(
LocalDate startDate,
LocalDate endDate,
@Positive(message = "Price per day must be positive") BigDecimal pricePerDay
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.devoops.accommodation.dto.response;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;

public record AvailabilityPeriodResponse(
UUID id,
UUID accommodationId,
LocalDate startDate,
LocalDate endDate,
BigDecimal pricePerDay,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.devoops.accommodation.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.SQLRestriction;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;

@Entity
@Table(name = "availability_periods")
@SQLRestriction("is_deleted = false")
@Getter
@Setter
@NoArgsConstructor
@SuperBuilder
public class AvailabilityPeriod extends BaseEntity {

@Column(name = "accommodation_id", nullable = false)
private UUID accommodationId;

@Column(name = "start_date", nullable = false)
private LocalDate startDate;

@Column(name = "end_date", nullable = false)
private LocalDate endDate;

@Column(name = "price_per_day", nullable = false, precision = 12, scale = 2)
private BigDecimal pricePerDay;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.accommodation.exception;

public class AvailabilityPeriodNotFoundException extends RuntimeException {
public AvailabilityPeriodNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,19 @@ public ProblemDetail handleInvalidContentType(InvalidContentTypeException ex) {
public ProblemDetail handleMaxUploadSize(MaxUploadSizeExceededException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the maximum allowed limit");
}

@ExceptionHandler(AvailabilityPeriodNotFoundException.class)
public ProblemDetail handleAvailabilityPeriodNotFound(AvailabilityPeriodNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

@ExceptionHandler(OverlappingAvailabilityPeriodException.class)
public ProblemDetail handleOverlappingAvailabilityPeriod(OverlappingAvailabilityPeriodException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}

@ExceptionHandler(ReservationConflictException.class)
public ProblemDetail handleReservationConflict(ReservationConflictException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.accommodation.exception;

public class OverlappingAvailabilityPeriodException extends RuntimeException {
public OverlappingAvailabilityPeriodException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.devoops.accommodation.exception;

public class ReservationConflictException extends RuntimeException {
public ReservationConflictException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.devoops.accommodation.grpc;

import com.devoops.accommodation.grpc.proto.CheckReservationsExistRequest;
import com.devoops.accommodation.grpc.proto.CheckReservationsExistResponse;
import com.devoops.accommodation.grpc.proto.ReservationInternalServiceGrpc;
import lombok.extern.slf4j.Slf4j;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.UUID;

@Component
@Slf4j
public class ReservationGrpcClient {

@GrpcClient("reservation-service")
private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub;

public boolean hasApprovedReservations(UUID accommodationId, LocalDate startDate, LocalDate endDate) {
log.debug("gRPC: Checking approved reservations for accommodation {} between {} and {}",
accommodationId, startDate, endDate);

CheckReservationsExistRequest request = CheckReservationsExistRequest.newBuilder()
.setAccommodationId(accommodationId.toString())
.setStartDate(startDate.toString())
.setEndDate(endDate.toString())
.build();

CheckReservationsExistResponse response = reservationStub.checkReservationsExist(request);
return response.getHasReservations();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.devoops.accommodation.mapper;

import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest;
import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse;
import com.devoops.accommodation.entity.AvailabilityPeriod;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

import java.util.List;

@Mapper(componentModel = "spring")
public interface AvailabilityPeriodMapper {

@Mapping(target = "id", ignore = true)
@Mapping(target = "accommodationId", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "isDeleted", ignore = true)
AvailabilityPeriod toEntity(CreateAvailabilityPeriodRequest request);

AvailabilityPeriodResponse toResponse(AvailabilityPeriod period);

List<AvailabilityPeriodResponse> toResponseList(List<AvailabilityPeriod> periods);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.devoops.accommodation.repository;

import com.devoops.accommodation.entity.AvailabilityPeriod;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface AvailabilityPeriodRepository extends JpaRepository<AvailabilityPeriod, UUID> {

List<AvailabilityPeriod> findByAccommodationIdOrderByStartDateAsc(UUID accommodationId);

Optional<AvailabilityPeriod> findByIdAndAccommodationId(UUID id, UUID accommodationId);

@Query("""
SELECT ap FROM AvailabilityPeriod ap
WHERE ap.accommodationId = :accommodationId
AND ap.startDate < :endDate
AND ap.endDate > :startDate
""")
List<AvailabilityPeriod> findOverlapping(
@Param("accommodationId") UUID accommodationId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);

@Query("""
SELECT ap FROM AvailabilityPeriod ap
WHERE ap.accommodationId = :accommodationId
AND ap.id != :excludeId
AND ap.startDate < :endDate
AND ap.endDate > :startDate
""")
List<AvailabilityPeriod> findOverlappingExcluding(
@Param("accommodationId") UUID accommodationId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("excludeId") UUID excludeId
);
}
Loading