diff --git a/README.md b/README.md new file mode 100644 index 0000000..7819193 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# TicketBookingApp +Seat reservation system for a multiplex. + +### Technologies used +* Java +* Spring Boot +* H2 Database + +### Assumptions +* There cannot be a single place left over in a row between two already reserved places. The seat that is on the edge can always be reserved. +* Seats can be booked at latest 15 minutes before the screening begins. +* Reservation expiration time is set to 10 minutes before the screening begins. + +### Build +Application is written in Java JDK version 11. The application uses Gradle wrapper to build and then run app using *java -jar*. + +To build and run app type command: +*bash build_and_run_script.sh PORT_NUMBER* +where *PORT_NUMBER* is number of port of localhost on which app runs. When *PORT_NUMBER* is not defined, application starts on port 8080. + +If is problem to run script, check if file *gradlew* has a execution permision. + +### Run use case +There are two scripts which run use case. + +First is *run_use_cases.sh* which uses **jq** for pretty JSON formatting. + +Second is *run_use_cases_without_jq.sh* which print JSON in normal way (as String). + +To run use case type command: +*bash SCRIPT_NAME PORT_NUMBER* +where *PORT_NUMBER* is number of port of localhost on which app runs. When *PORT_NUMBER* is not defined, application make calls on port 8080. + diff --git a/build.gradle b/build.gradle index fd5a4fd..14e1494 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { apply plugin: 'io.spring.dependency-management' group = 'com.mkopec' -version = '0.0.1-SNAPSHOT' +version = '0.0.1' sourceCompatibility = '11' configurations { @@ -19,6 +19,10 @@ repositories { mavenCentral() } +bootJar { + launchScript() +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -26,4 +30,9 @@ dependencies { runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + + compile group: 'org.mapstruct', name: 'mapstruct-jdk8', version: '1.2.0.Final' + compileOnly 'org.mapstruct:mapstruct-processor:1.2.0.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.2.0.Final' + } diff --git a/build_and_run_script.sh b/build_and_run_script.sh new file mode 100755 index 0000000..3612623 --- /dev/null +++ b/build_and_run_script.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh + +passed_port=$1 +default_port=8080 +port=${passed_port:-$default_port} + +./gradlew build && java -jar build/libs/ticket-booking-app-0.0.1.jar --server.port=$((port)) diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/run_use_cases.sh b/run_use_cases.sh new file mode 100644 index 0000000..6b1e2de --- /dev/null +++ b/run_use_cases.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +generate_post_data() +{ +cat < getScreeningsInDayAndTimeInterval( + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, + @RequestParam("timeStart") @DateTimeFormat(pattern = "HH:mm:ss") LocalTime timeStart, + @RequestParam("timeEnd") @DateTimeFormat(pattern = "HH:mm:ss") LocalTime timeEnd) { + + return mapper.toShortScreeningDTOs(service.findAllInDayAndTimeInterval(date, timeStart, timeEnd)); + } + + @GetMapping("/{screeningID}") + public ScreeningDTO getScreeningDetails(@PathVariable Long screeningID) { + ScreeningDTO screeningDTO = mapper.toScreeningDTO(service.findByID(screeningID)); + screeningDTO.setAvailableSeats(roomSeatMapper.toRoomSeatDTOs(service.findAvailableSeats(screeningID))); + return screeningDTO; + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/Movie.java b/src/main/java/com/mkopec/ticketbookingapp/domain/Movie.java new file mode 100644 index 0000000..4b4dfd0 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/Movie.java @@ -0,0 +1,26 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.Basic; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class Movie { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private String title; + + private String director; + + @Basic + private java.time.LocalTime length; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/Reservation.java b/src/main/java/com/mkopec/ticketbookingapp/domain/Reservation.java new file mode 100644 index 0000000..d23c7de --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/Reservation.java @@ -0,0 +1,39 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static javax.persistence.CascadeType.PERSIST; +import static javax.persistence.FetchType.LAZY; +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class Reservation { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "screening_id") + private Screening screening; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Basic + private LocalDateTime expirationTime; + + private BigDecimal payment; + + @OneToMany(mappedBy = "reservation", fetch = LAZY, cascade = PERSIST) + private List tickets; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/Room.java b/src/main/java/com/mkopec/ticketbookingapp/domain/Room.java new file mode 100644 index 0000000..4e10913 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/Room.java @@ -0,0 +1,20 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class Room { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private Integer number; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/RoomSeat.java b/src/main/java/com/mkopec/ticketbookingapp/domain/RoomSeat.java new file mode 100644 index 0000000..516aba4 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/RoomSeat.java @@ -0,0 +1,29 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.*; + +import static javax.persistence.FetchType.LAZY; +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class RoomSeat { + + @Id + @GeneratedValue(strategy = IDENTITY) + + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "seat_id") + private Seat seat; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "room_id") + private Room room; + + @Column(name = "is_on_edge") + private Boolean isOnEdge; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/Screening.java b/src/main/java/com/mkopec/ticketbookingapp/domain/Screening.java new file mode 100644 index 0000000..7bfbb8f --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/Screening.java @@ -0,0 +1,29 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.*; +import java.time.LocalDateTime; + +import static javax.persistence.FetchType.LAZY; +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class Screening { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "room_id") + private Room room; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "movie_id") + private Movie movie; + + @Basic + private LocalDateTime date; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/Seat.java b/src/main/java/com/mkopec/ticketbookingapp/domain/Seat.java new file mode 100644 index 0000000..d841380 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/Seat.java @@ -0,0 +1,24 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class Seat { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(name = "row_name") + private String row; + + private Integer number; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/Ticket.java b/src/main/java/com/mkopec/ticketbookingapp/domain/Ticket.java new file mode 100644 index 0000000..d11a57f --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/Ticket.java @@ -0,0 +1,33 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.*; + +import static javax.persistence.FetchType.LAZY; +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class Ticket { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "roomSeat_id") + private RoomSeat seat; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "reservation_id") + private Reservation reservation; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "screening_id") + private Screening screening; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "ticket_type_id") + private TicketType ticketType; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/domain/TicketType.java b/src/main/java/com/mkopec/ticketbookingapp/domain/TicketType.java new file mode 100644 index 0000000..48d316d --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/domain/TicketType.java @@ -0,0 +1,25 @@ +package com.mkopec.ticketbookingapp.domain; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import java.math.BigDecimal; + +import static javax.persistence.GenerationType.IDENTITY; + +@Entity +@Data +public class TicketType { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private String typeName; + + private BigDecimal price; + + private String currency; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/dtos/ReservationPostDTO.java b/src/main/java/com/mkopec/ticketbookingapp/dtos/ReservationPostDTO.java new file mode 100644 index 0000000..11ce131 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/dtos/ReservationPostDTO.java @@ -0,0 +1,13 @@ +package com.mkopec.ticketbookingapp.dtos; + +import lombok.Data; + +import java.util.List; + +@Data +public class ReservationPostDTO { + private Long screeningID; + private String firstName; + private String lastName; + private List tickets; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/dtos/RoomSeatDTO.java b/src/main/java/com/mkopec/ticketbookingapp/dtos/RoomSeatDTO.java new file mode 100644 index 0000000..b6ea8d0 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/dtos/RoomSeatDTO.java @@ -0,0 +1,10 @@ +package com.mkopec.ticketbookingapp.dtos; + +import lombok.Data; + +@Data +public class RoomSeatDTO { + private Long id; + private String row; + private Integer number; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/dtos/ScreeningDTO.java b/src/main/java/com/mkopec/ticketbookingapp/dtos/ScreeningDTO.java new file mode 100644 index 0000000..584d390 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/dtos/ScreeningDTO.java @@ -0,0 +1,15 @@ +package com.mkopec.ticketbookingapp.dtos; + +import lombok.Data; + +import java.time.LocalTime; +import java.util.List; + +@Data +public class ScreeningDTO { + private Long id; + private String title; + private Integer roomNumber; + private LocalTime startTime; + private List availableSeats; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/dtos/ShortReservationDTO.java b/src/main/java/com/mkopec/ticketbookingapp/dtos/ShortReservationDTO.java new file mode 100644 index 0000000..45be2da --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/dtos/ShortReservationDTO.java @@ -0,0 +1,13 @@ +package com.mkopec.ticketbookingapp.dtos; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +public class ShortReservationDTO { + private Long id; + private LocalDateTime expirationTime; + private BigDecimal payment; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/dtos/ShortScreeningDTO.java b/src/main/java/com/mkopec/ticketbookingapp/dtos/ShortScreeningDTO.java new file mode 100644 index 0000000..ed3ceba --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/dtos/ShortScreeningDTO.java @@ -0,0 +1,12 @@ +package com.mkopec.ticketbookingapp.dtos; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class ShortScreeningDTO { + private Long id; + private String title; + private LocalDateTime date; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/dtos/TicketPostDTO.java b/src/main/java/com/mkopec/ticketbookingapp/dtos/TicketPostDTO.java new file mode 100644 index 0000000..1338cab --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/dtos/TicketPostDTO.java @@ -0,0 +1,9 @@ +package com.mkopec.ticketbookingapp.dtos; + +import lombok.Data; + +@Data +public class TicketPostDTO { + private Long seatID; + private Long ticketTypeID; +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/exception/ArgumentNotValidException.java b/src/main/java/com/mkopec/ticketbookingapp/exception/ArgumentNotValidException.java new file mode 100644 index 0000000..5ec3901 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/exception/ArgumentNotValidException.java @@ -0,0 +1,15 @@ +package com.mkopec.ticketbookingapp.exception; + + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Getter +@ResponseStatus(value = HttpStatus.BAD_REQUEST) +public class ArgumentNotValidException extends RuntimeException { + + public ArgumentNotValidException(String message) { + super(message); + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/exception/ResourceNotFoundException.java b/src/main/java/com/mkopec/ticketbookingapp/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..31c9ecd --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/exception/ResourceNotFoundException.java @@ -0,0 +1,22 @@ +package com.mkopec.ticketbookingapp.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@Getter +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + private String resourceName; + + private String fieldName; + + private Object fieldValue; + + public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) { + super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue)); + this.resourceName = resourceName; + this.fieldName = fieldName; + this.fieldValue = fieldValue; + } +} \ No newline at end of file diff --git a/src/main/java/com/mkopec/ticketbookingapp/init/SqlFunction.java b/src/main/java/com/mkopec/ticketbookingapp/init/SqlFunction.java new file mode 100644 index 0000000..c2ffb7c --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/init/SqlFunction.java @@ -0,0 +1,23 @@ +package com.mkopec.ticketbookingapp.init; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public class SqlFunction { + public static void ticketInsert(Connection connection, String row, String numbers, + Long reservationID, Long ticketTypeID) throws SQLException { + String sqlStatement = + "INSERT INTO ticket(room_seat_id, screening_id, reservation_id, ticket_type_id) " + + " SELECT rs.id, s.id, res.id, '" + ticketTypeID + "'" + + " FROM reservation res " + + " JOIN screening s ON res.screening_id = s.id " + + " JOIN room_seat rs ON s.room_id = rs.room_id " + + " JOIN seat ON rs.seat_id = seat.id " + + " WHERE seat.row_name = '" + row + "' " + + " AND seat.number IN " + numbers + + " AND res.id = '" + reservationID + "'"; + PreparedStatement ps = connection.prepareStatement(sqlStatement); + ps.execute(); + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/mapper/ReservationMapper.java b/src/main/java/com/mkopec/ticketbookingapp/mapper/ReservationMapper.java new file mode 100644 index 0000000..c063dbb --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/mapper/ReservationMapper.java @@ -0,0 +1,24 @@ +package com.mkopec.ticketbookingapp.mapper; + +import com.mkopec.ticketbookingapp.domain.Reservation; +import com.mkopec.ticketbookingapp.dtos.ReservationPostDTO; +import com.mkopec.ticketbookingapp.dtos.ShortReservationDTO; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; +import org.springframework.beans.factory.annotation.Autowired; + +@Mapper(componentModel = "spring") +public abstract class ReservationMapper { + + @Autowired + protected TicketMapper ticketMapper; + + public abstract ShortReservationDTO toShortReservationDTO(Reservation reservation); + + @Mappings({ + @Mapping(target = "screening.id", source = "screeningID"), + @Mapping(target = "tickets", expression = "java(ticketMapper.toTickets(postDTO.getTickets()))") + }) + public abstract Reservation toReservation(ReservationPostDTO postDTO); +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/mapper/RoomSeatMapper.java b/src/main/java/com/mkopec/ticketbookingapp/mapper/RoomSeatMapper.java new file mode 100644 index 0000000..098f1f6 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/mapper/RoomSeatMapper.java @@ -0,0 +1,21 @@ +package com.mkopec.ticketbookingapp.mapper; + +import com.mkopec.ticketbookingapp.domain.RoomSeat; +import com.mkopec.ticketbookingapp.dtos.RoomSeatDTO; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +import java.util.List; + +@Mapper(componentModel = "spring") +public abstract class RoomSeatMapper { + + @Mappings({ + @Mapping(target = "row", source = "seat.row"), + @Mapping(target = "number", source = "seat.number") + }) + public abstract RoomSeatDTO toRoomSeatDTO(RoomSeat roomSeat); + + public abstract List toRoomSeatDTOs(List roomSeatList); +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/mapper/ScreeningMapper.java b/src/main/java/com/mkopec/ticketbookingapp/mapper/ScreeningMapper.java new file mode 100644 index 0000000..f628773 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/mapper/ScreeningMapper.java @@ -0,0 +1,26 @@ +package com.mkopec.ticketbookingapp.mapper; + +import com.mkopec.ticketbookingapp.domain.Screening; +import com.mkopec.ticketbookingapp.dtos.ScreeningDTO; +import com.mkopec.ticketbookingapp.dtos.ShortScreeningDTO; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +import java.util.List; + +@Mapper(componentModel = "spring") +public abstract class ScreeningMapper { + + @Mapping(target = "title", source = "movie.title") + public abstract ShortScreeningDTO toShortScreeningDTO(Screening screening); + + public abstract List toShortScreeningDTOs(List screenings); + + @Mappings({ + @Mapping(target = "title", source = "movie.title"), + @Mapping(target = "roomNumber", source = "room.number"), + @Mapping(target = "startTime", expression = "java(screening.getDate().toLocalTime())") + }) + public abstract ScreeningDTO toScreeningDTO(Screening screening); +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/mapper/TicketMapper.java b/src/main/java/com/mkopec/ticketbookingapp/mapper/TicketMapper.java new file mode 100644 index 0000000..ad00cfb --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/mapper/TicketMapper.java @@ -0,0 +1,21 @@ +package com.mkopec.ticketbookingapp.mapper; + +import com.mkopec.ticketbookingapp.domain.Ticket; +import com.mkopec.ticketbookingapp.dtos.TicketPostDTO; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Mappings; + +import java.util.List; + +@Mapper(componentModel = "spring") +public abstract class TicketMapper { + + @Mappings({ + @Mapping(target = "seat.id", source = "seatID"), + @Mapping(target = "ticketType.id", source = "ticketTypeID") + }) + public abstract Ticket toTicket(TicketPostDTO postDTO); + + public abstract List toTickets(List ticketPostDTOList); +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/repository/ReservationRepository.java b/src/main/java/com/mkopec/ticketbookingapp/repository/ReservationRepository.java new file mode 100644 index 0000000..24393d5 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/repository/ReservationRepository.java @@ -0,0 +1,10 @@ +package com.mkopec.ticketbookingapp.repository; + +import com.mkopec.ticketbookingapp.domain.Reservation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReservationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/repository/RoomSeatRepository.java b/src/main/java/com/mkopec/ticketbookingapp/repository/RoomSeatRepository.java new file mode 100644 index 0000000..98eebca --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/repository/RoomSeatRepository.java @@ -0,0 +1,24 @@ +package com.mkopec.ticketbookingapp.repository; + +import com.mkopec.ticketbookingapp.domain.RoomSeat; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RoomSeatRepository extends JpaRepository { + + @Query("SELECT rs FROM RoomSeat rs LEFT OUTER JOIN Ticket t ON rs.id = t.seat.id " + + "WHERE t.id IS NULL AND rs.room.id = (SELECT s.room.id FROM Screening s WHERE s.id = ?1)") + List findAvailableRoomSeats(Long screeningID); + + @Query("SELECT rs FROM RoomSeat rs JOIN Seat s ON rs.seat.id = s.id " + + "WHERE rs.room.id = ?1 AND s.row IN (SELECT s.row FROM RoomSeat rs JOIN Seat s ON rs.seat.id = s.id WHERE rs.id IN ?2)") + List findSeatsInRowByRoomIdAndRoomSeatIds(Long roomID, List roomSeatIDs); + + @Query("SELECT rs FROM Ticket t JOIN RoomSeat rs ON t.seat.id = rs.id JOIN Seat s ON rs.seat.id = s.id" + + " WHERE t.screening.id = ?1 AND s.row IN (SELECT s.row FROM RoomSeat rs JOIN Seat s ON rs.seat.id = s.id WHERE rs.id IN ?2)") + List findOccupiedSeatsInRowByScreeningIdAndRoomSeatIds(Long screeningID, List roomSeatsIDs); +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/repository/ScreeningRepository.java b/src/main/java/com/mkopec/ticketbookingapp/repository/ScreeningRepository.java new file mode 100644 index 0000000..2484b2e --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/repository/ScreeningRepository.java @@ -0,0 +1,16 @@ +package com.mkopec.ticketbookingapp.repository; + +import com.mkopec.ticketbookingapp.domain.Screening; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface ScreeningRepository extends JpaRepository { + + @Query("SELECT s FROM Screening s WHERE s.date BETWEEN ?1 AND ?2 ORDER BY s.movie.title, s.date") + List findByDateAndTimeInterval(LocalDateTime dateStart, LocalDateTime dateEnd); +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/repository/TicketTypeRepository.java b/src/main/java/com/mkopec/ticketbookingapp/repository/TicketTypeRepository.java new file mode 100644 index 0000000..db80332 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/repository/TicketTypeRepository.java @@ -0,0 +1,8 @@ +package com.mkopec.ticketbookingapp.repository; + +import com.mkopec.ticketbookingapp.domain.TicketType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TicketTypeRepository extends JpaRepository { + +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/service/ReservationService.java b/src/main/java/com/mkopec/ticketbookingapp/service/ReservationService.java new file mode 100644 index 0000000..ffbcd5a --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/service/ReservationService.java @@ -0,0 +1,41 @@ +package com.mkopec.ticketbookingapp.service; + +import com.mkopec.ticketbookingapp.domain.Reservation; +import com.mkopec.ticketbookingapp.repository.ReservationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.math.BigDecimal; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +@Service +@RequiredArgsConstructor +public class ReservationService { + private final ReservationRepository repository; + private final ReservationValidationService validationService; + private final TicketTypeService ticketTypeService; + + @Transactional + public Reservation saveReservation(Reservation reservation) { + + reservation.getTickets().forEach( + ticket -> { + ticket.setReservation(reservation); + ticket.setScreening(reservation.getScreening()); + } + ); + + validationService.validate(reservation); + reservation.setExpirationTime(validationService.getExpirationTime(reservation)); + + List ticketTypesIDs = reservation.getTickets().stream() + .map(ticket -> ticket.getTicketType().getId()).collect(toList()); + BigDecimal payment = ticketTypeService.getPaymentForTickets(ticketTypesIDs); + reservation.setPayment(payment); + + return repository.save(reservation); + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/service/ReservationValidationService.java b/src/main/java/com/mkopec/ticketbookingapp/service/ReservationValidationService.java new file mode 100644 index 0000000..0e29659 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/service/ReservationValidationService.java @@ -0,0 +1,77 @@ +package com.mkopec.ticketbookingapp.service; + +import com.mkopec.ticketbookingapp.domain.Reservation; +import com.mkopec.ticketbookingapp.domain.RoomSeat; +import com.mkopec.ticketbookingapp.domain.Screening; +import com.mkopec.ticketbookingapp.domain.Ticket; +import com.mkopec.ticketbookingapp.exception.ArgumentNotValidException; +import com.mkopec.ticketbookingapp.repository.RoomSeatRepository; +import com.mkopec.ticketbookingapp.repository.ScreeningRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.regex.Pattern; + +import static java.util.stream.Collectors.toList; + +@Service +@RequiredArgsConstructor +public class ReservationValidationService { + private final ScreeningRepository screeningRepository; + private final RoomSeatRepository roomSeatRepository; + private final SeatValidationService seatValidationService; + + private static final long MIN_MINUTES_TO_START = 15; + private static final long MINUTES_TO_START_SCREENING = 10; + + private final Pattern firstNamePattern = Pattern.compile("\\b[([A-Z][a-z]*)]{3,50}\\b"); + private final Pattern lastNamePattern = Pattern.compile("\\b[([A-Z][a-z]*-[A-Z][a-z]*)]{3,50}\\b"); + + void validate(Reservation reservation) { + Screening screening = screeningRepository.getOne(reservation.getScreening().getId()); + checkReservationScreening(screening); + checkReservationTickets(reservation.getTickets(), screening); + + if (!isFirstNameValid(reservation.getFirstName())){ + throw new ArgumentNotValidException("First name is not valid"); + } + + if(!isLastNameValid(reservation.getLastName())) { + throw new ArgumentNotValidException("Last name is not valid"); + } + } + + private void checkReservationScreening(Screening screening) throws ArgumentNotValidException { + long minutesToScreeningStart = ChronoUnit.MINUTES.between(LocalDateTime.now(), screening.getDate()); + if (minutesToScreeningStart < MIN_MINUTES_TO_START) { + throw new ArgumentNotValidException("Is to late to make reservation for this screening"); + } + } + + private boolean checkReservationTickets(List tickets, Screening screening) throws ArgumentNotValidException { + List roomSeatIDs = tickets.stream().map(ticket -> ticket.getSeat().getId()).collect(toList()); + + List reservedSeats = roomSeatRepository.findAllById(roomSeatIDs); + List allSeatsInRows = roomSeatRepository.findSeatsInRowByRoomIdAndRoomSeatIds(screening.getRoom().getId(), roomSeatIDs); + List occupiedSeatsInRows = roomSeatRepository.findOccupiedSeatsInRowByScreeningIdAndRoomSeatIds(screening.getId(), roomSeatIDs); + + return seatValidationService.validate(reservedSeats, allSeatsInRows, occupiedSeatsInRows); + } + + LocalDateTime getExpirationTime(Reservation reservation) { + Screening screening = screeningRepository.getOne(reservation.getScreening().getId()); + LocalDateTime screeningTime = screening.getDate(); + return screeningTime.minusMinutes(MINUTES_TO_START_SCREENING); + } + + private boolean isFirstNameValid(String firstName) { + return firstName.matches(firstNamePattern.pattern()); + } + + private boolean isLastNameValid(String lastName) { + return lastName.matches(lastNamePattern.pattern()); + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/service/ScreeningService.java b/src/main/java/com/mkopec/ticketbookingapp/service/ScreeningService.java new file mode 100644 index 0000000..adca543 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/service/ScreeningService.java @@ -0,0 +1,34 @@ +package com.mkopec.ticketbookingapp.service; + +import com.mkopec.ticketbookingapp.domain.RoomSeat; +import com.mkopec.ticketbookingapp.domain.Screening; +import com.mkopec.ticketbookingapp.exception.ResourceNotFoundException; +import com.mkopec.ticketbookingapp.repository.RoomSeatRepository; +import com.mkopec.ticketbookingapp.repository.ScreeningRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ScreeningService { + private final ScreeningRepository repository; + private final RoomSeatRepository roomSeatRepository; + + public List findAllInDayAndTimeInterval(LocalDate date, LocalTime timeStart, LocalTime timeEnd) { + return repository.findByDateAndTimeInterval(LocalDateTime.of(date, timeStart), LocalDateTime.of(date, timeEnd)); + } + + public Screening findByID(Long screeningID) { + return repository.findById(screeningID) + .orElseThrow(() -> new ResourceNotFoundException("Screening", "id", screeningID)); + } + + public List findAvailableSeats(Long screeningID) { + return roomSeatRepository.findAvailableRoomSeats(screeningID); + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/service/SeatValidationService.java b/src/main/java/com/mkopec/ticketbookingapp/service/SeatValidationService.java new file mode 100644 index 0000000..2e38687 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/service/SeatValidationService.java @@ -0,0 +1,199 @@ +package com.mkopec.ticketbookingapp.service; + +import com.mkopec.ticketbookingapp.domain.RoomSeat; +import com.mkopec.ticketbookingapp.exception.ArgumentNotValidException; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.groupingBy; + +@Service +public class SeatValidationService { + private enum SeatType { + FREE_INSIDE, + FREE_ON_EDGE, + OCCUPIED, + RESERVATION_DONE + } + + private static class SeatModel { + private SeatType seatType; + private RoomSeat roomSeat; + private boolean reserved = false; + + private SeatModel(RoomSeat roomSeat, SeatType seatType) { + this.seatType = seatType; + this.roomSeat = roomSeat; + } + + String getRow() { + return roomSeat.getSeat().getRow(); + } + + Integer getNumber() { + return roomSeat.getSeat().getNumber(); + } + } + + private static final int NUM_OF_NEIGHBOURS = 2; + + boolean validate(List reservedSeats, List occupiedSeats, List allSeats) throws ArgumentNotValidException { + Map seats = initializeSeatModels(reservedSeats, occupiedSeats, allSeats); + + // sorting allows to take neighbour of seat + Map> groupedSeatModelsToRows = seats.values().stream() + .sorted(Comparator.comparing(SeatModel::getRow).thenComparing(SeatModel::getNumber)) + .collect(groupingBy(SeatModel::getRow)); + + for (Map.Entry> entry : groupedSeatModelsToRows.entrySet()) { + List models = entry.getValue(); + for (int i = 0; i < models.size(); i++) { + SeatModel model = models.get(i); + if (model.reserved) { + // check if is possible to reserve + switch (model.seatType) { + case FREE_ON_EDGE: { + model.seatType = SeatType.RESERVATION_DONE; + break; + } + case FREE_INSIDE: { + List neighbourhood = getNeighbourhoodOfSeat(models, i); + if (isReservationValidForSeatModel(neighbourhood, model)) { + model.seatType = SeatType.RESERVATION_DONE; + } else { + throw new ArgumentNotValidException("Dont left single seat near seat: " + model.roomSeat.getId()); + } + break; + } + } + } + } + } + + return true; + } + + private Map initializeSeatModels(List reservedSeats, List occupiedSeats, List allSeats) { + Map seats = new HashMap<>(); + + allSeats.forEach(rs -> seats.put(rs, new SeatModel(rs, rs.getIsOnEdge() ? SeatType.FREE_ON_EDGE : SeatType.FREE_INSIDE))); + occupiedSeats.forEach(rs -> { + if (seats.containsKey(rs)) { + seats.put(rs, new SeatModel(rs, SeatType.OCCUPIED)); + } + }); + reservedSeats.forEach(rs -> { + if (seats.containsKey(rs)) { + SeatModel seatModel = seats.get(rs); + if (!seatModel.seatType.equals(SeatType.OCCUPIED)) { + seatModel.reserved = true; + } else { + throw new ArgumentNotValidException("Seat with ID: " + seatModel.roomSeat.getId() + " is already reserved"); + } + } + }); + return seats; + } + + private List getNeighbourhoodOfSeat(List models, int index) { + int lastElementIndex = models.size() - 1; + + int fromIndex = index < NUM_OF_NEIGHBOURS ? 0 : index - NUM_OF_NEIGHBOURS; + int toIndex = index > lastElementIndex - NUM_OF_NEIGHBOURS ? lastElementIndex : index + 1 + NUM_OF_NEIGHBOURS; + + return models.subList(fromIndex, toIndex); + } + + private boolean isReservationValidForSeatModel(List neighbourhood, SeatModel model) { + int modelIndex = neighbourhood.indexOf(model); + boolean isFreeSeatLeft = false; + + if (hasPrevious(modelIndex)) { + SeatModel p = getPrevious(neighbourhood, modelIndex); + switch (p.seatType) { + case FREE_INSIDE: { + if (hasPrevious(modelIndex - 1)) { + SeatModel pp = getPrevious(neighbourhood, modelIndex - 1); + switch (pp.seatType) { + case FREE_INSIDE: + case FREE_ON_EDGE: + isFreeSeatLeft = false; + break; + case OCCUPIED: + case RESERVATION_DONE: + isFreeSeatLeft = true; + break; + } + } + break; + } + case FREE_ON_EDGE: + isFreeSeatLeft = true; + break; + case OCCUPIED: + case RESERVATION_DONE: + return true; + } + } + + if (isFreeSeatLeft) { + if (hasNext(neighbourhood, modelIndex)) { + SeatModel n = getNext(neighbourhood, modelIndex); + switch (n.seatType) { + case FREE_INSIDE: + case FREE_ON_EDGE: + return false; + case OCCUPIED: + case RESERVATION_DONE: + break; + } + } + } else { + if (hasNext(neighbourhood, modelIndex)) { + SeatModel n = getNext(neighbourhood, modelIndex); + switch (n.seatType) { + case FREE_INSIDE: { + if (hasNext(neighbourhood, modelIndex + 1)) { + SeatModel nn = getNext(neighbourhood, modelIndex + 1); + switch (nn.seatType) { + case FREE_INSIDE: + case FREE_ON_EDGE: + break; + case OCCUPIED: + case RESERVATION_DONE: + return false; + } + } + break; + } + case FREE_ON_EDGE: + case OCCUPIED: + case RESERVATION_DONE: + break; + } + } + } + + return true; + } + + private SeatModel getNext(List neighbourhood, int index) { + return neighbourhood.get(index + 1); + } + + private SeatModel getPrevious(List neighbourhood, int index) { + return neighbourhood.get(index - 1); + } + + private boolean hasPrevious(int index) { + return index - 1 >= 0; + } + + private boolean hasNext(List neighbourhood, int index) { + return index + 1 < neighbourhood.size(); + } +} diff --git a/src/main/java/com/mkopec/ticketbookingapp/service/TicketTypeService.java b/src/main/java/com/mkopec/ticketbookingapp/service/TicketTypeService.java new file mode 100644 index 0000000..17b96f9 --- /dev/null +++ b/src/main/java/com/mkopec/ticketbookingapp/service/TicketTypeService.java @@ -0,0 +1,27 @@ +package com.mkopec.ticketbookingapp.service; + +import com.mkopec.ticketbookingapp.domain.TicketType; +import com.mkopec.ticketbookingapp.repository.TicketTypeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TicketTypeService { + private final TicketTypeRepository repository; + + BigDecimal getPaymentForTickets(List ticketTypesID) { + List ticketTypes = repository.findAll(); + + Map ticketTypeMap = ticketTypes.stream().collect(Collectors.toMap(TicketType::getId, Function.identity())); + + return ticketTypesID.stream().map(ticketID -> ticketTypeMap.get(ticketID).getPrice()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b13789..11f25f6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,10 @@ +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto = validate + +spring.h2.console.enabled=true +spring.h2.console.path=/console diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..6ed02ad --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,170 @@ +-- Script with data for H2 database + +-- --------------------------------------------------------------- +-- Helper views to load initial data +-- --------------------------------------------------------------- +CREATE VIEW sequence_of_int AS +SELECT ones.n + tens.n * 10 +FROM (VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9)) ones(n), + (VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9)) tens(n) +WHERE ones.n + tens.n * 10 BETWEEN 1 AND 10 +ORDER BY 1; + + +CREATE VIEW sequence_of_male_names AS +SELECT first_name.n AS firstname, last_name.n AS lastname +FROM (VALUES ('Michał'), ('Paweł'), ('Konrad')) first_name(n), + (VALUES ('Nowakowski'), ('Kowalski'), ('Kozłowski')) last_name(n); + + +CREATE VIEW sequence_of_female_names AS +SELECT first_name.n AS firstname, last_name.n AS lastname +FROM (VALUES ('Agata'), ('Anna'), ('Sylwia')) first_name(n), + (VALUES ('Zielińska'), ('Kalinowska-Jaworska'), ('Zając')) last_name(n); + + +CREATE VIEW screening_times AS + SELECT TIMESTAMPADD(hour, 15, TIMESTAMPADD(day, 1, CURRENT_DATE)) + UNION + SELECT TIMESTAMPADD(hour, 18, TIMESTAMPADD(day, 1, CURRENT_DATE)) + UNION + SELECT TIMESTAMPADD(hour, 21, TIMESTAMPADD(day, 1, CURRENT_DATE)) + UNION + SELECT TIMESTAMPADD(hour, 15, TIMESTAMPADD(day, 2, CURRENT_DATE)) + UNION + SELECT TIMESTAMPADD(hour, 18, TIMESTAMPADD(day, 2, CURRENT_DATE)) + UNION + SELECT TIMESTAMPADD(hour, 21, TIMESTAMPADD(day, 2, CURRENT_DATE)); + +-- --------------------------------------------------------------- +-- table: Seat +-- --------------------------------------------------------------- +INSERT INTO seat(row_name, number) +SELECT rows.n, t.* +FROM (VALUES ('A'), ('B'), ('C'), ('D'), ('E'), ('F')) rows(n) + CROSS JOIN sequence_of_int AS t; + +-- --------------------------------------------------------------- +-- table: Room +-- --------------------------------------------------------------- +INSERT INTO room(number) +SELECT * +FROM sequence_of_int; + +-- --------------------------------------------------------------- +-- table: RoomSeat +-- --------------------------------------------------------------- +INSERT INTO room_seat(room_id, seat_id, is_on_edge) +SELECT room.id, seat.id, FALSE +FROM room, + seat; + +-- after insert of all room_seats we know which ones are on the edge +UPDATE room_seat +SET room_seat.is_on_edge = TRUE +WHERE room_seat.id IN + (SELECT rs.id + FROM room_seat rs + JOIN seat s ON rs.seat_id = s.id + JOIN (SELECT rs.room_id AS rid, s.row_name AS r, MAX(s.number) AS m + FROM room_seat rs + JOIN seat s ON rs.seat_id = s.id + GROUP BY rid, r + UNION + SELECT rs.room_id AS rid, s.row_name AS r, MIN(s.number) AS m + FROM room_seat rs + JOIN seat s ON rs.seat_id = s.id + GROUP BY rid, r) AS temp + WHERE rs.room_id = temp.rid + AND s.row_name = temp.r + AND s.number = temp.m); + +-- --------------------------------------------------------------- +-- table: Movie +-- --------------------------------------------------------------- +INSERT INTO movie( title + , director + , length) +VALUES ('Pulp Fiction', + 'Quentin Tarantino', + '02:34:00'), + ('Incepcja', + 'Christopher Nolan', + '02:28:00'), + ('Lot nad kukułczym gniazdem', + 'Miros Forman', + '02:13:00'); + +-- --------------------------------------------------------------- +-- table: Screening +-- --------------------------------------------------------------- +INSERT INTO screening(room_id, movie_id, date) +SELECT 1, 1, t.* +FROM screening_times AS t +UNION +SELECT 2, 2, t.* +FROM screening_times AS t +UNION +SELECT 3, 3, t.* +FROM screening_times AS t; + +INSERT INTO screening(room_id, movie_id, date) +VALUES (4, 3, '2019-07-01 09:20:00'); + +-- --------------------------------------------------------------- +-- table: TicketType +-- --------------------------------------------------------------- +INSERT INTO ticket_type(type_name, price, currency) +VALUES ('Adult', 25.00, 'PLN'), + ('Student', 18.00, 'PLN'), + ('Child', 12.50, 'PLN'); + +-- --------------------------------------------------------------- +-- table: Reservation +-- --------------------------------------------------------------- +INSERT INTO reservation(screening_id, first_name, last_name, expiration_time, payment) +SELECT 1, firstname, lastname, CURRENT_TIMESTAMP(), 0 +FROM sequence_of_male_names +UNION +SELECT 2, firstname, lastname, CURRENT_TIMESTAMP(), 0 +FROM sequence_of_female_names; + +-- --------------------------------------------------------------- +-- table: Ticket +-- --------------------------------------------------------------- +-- ticket_insert params: +-- row - String +-- numbers - String in format (1,2,3) +-- reservationID - Long +-- ticketTypeID - Long +CREATE ALIAS ticket_insert FOR "com.mkopec.ticketbookingapp.init.SqlFunction.ticketInsert"; + +SELECT ticket_insert('A', '(3,4)', 1, 1); +SELECT ticket_insert('A', '(6)', 2, 1); +SELECT ticket_insert('A', '(7)', 3, 1); +SELECT ticket_insert('B', '(6,7,8)', 4, 2); +SELECT ticket_insert('C', '(1,2,3,4)', 5, 2); +SELECT ticket_insert('C', '(8,9)', 6, 3); +SELECT ticket_insert('D', '(2,3)', 7, 3); +SELECT ticket_insert('D', '(7,8)', 8, 2); +SELECT ticket_insert('E', '(5)', 9, 1); + +SELECT ticket_insert('A', '(3,4)', 10, 1); +SELECT ticket_insert('A', '(6)', 11, 1); +SELECT ticket_insert('A', '(7)', 12, 1); +SELECT ticket_insert('B', '(6,7,8)', 13, 2); +SELECT ticket_insert('C', '(1,2,3,4)', 14, 2); +SELECT ticket_insert('C', '(8,9)', 15, 3); +SELECT ticket_insert('D', '(2,3)', 16, 3); +SELECT ticket_insert('D', '(7,8)', 17, 2); +SELECT ticket_insert('E', '(5)', 18, 1); + +-- after insert tickets we need to update reservation payment +UPDATE reservation res +SET res.payment = + (SELECT SUM(tt.price) + FROM ticket t + JOIN ticket_type AS tt ON t.ticket_type_id = tt.id + WHERE res.id = t.reservation_id); + + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..aea70d0 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,127 @@ +-- Create tables script for H2 database + +-- --------------------------------------------------------------- +-- table: Seat +-- --------------------------------------------------------------- +CREATE TABLE seat +( + id BIGINT NOT NULL AUTO_INCREMENT, + row_name VARCHAR(3) NOT NULL, + number INT NOT NULL, + PRIMARY KEY (id) +); + +CREATE INDEX idx_seat_row_number ON seat (row_name, number); + +-- --------------------------------------------------------------- +-- table: Room +-- --------------------------------------------------------------- +CREATE TABLE room +( + id BIGINT NOT NULL AUTO_INCREMENT, + number INT NOT NULL, + PRIMARY KEY (id) +); + +CREATE INDEX idx_room_number ON room (number); + +-- --------------------------------------------------------------- +-- table: RoomSeat +-- --------------------------------------------------------------- +CREATE TABLE room_seat +( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + seat_id BIGINT NOT NULL, + is_on_edge BOOLEAN NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (room_id) REFERENCES room (id), + FOREIGN KEY (seat_id) REFERENCES seat (id), +); + +CREATE INDEX idx_room_seat_room_id ON room_seat (room_id); +CREATE INDEX idx_room_seat_seat_id ON room_seat (seat_id); + +-- --------------------------------------------------------------- +-- table: Movie +-- --------------------------------------------------------------- +CREATE TABLE movie +( + id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR(250) NOT NULL, + director VARCHAR(250) NOT NULL, + length TIME NOT NULL, + PRIMARY KEY (id) +); + +CREATE INDEX idx_movie_title_director ON movie (title, director); + +-- --------------------------------------------------------------- +-- table: Screening +-- --------------------------------------------------------------- +CREATE TABLE screening +( + id BIGINT NOT NULL AUTO_INCREMENT, + room_id BIGINT NOT NULL, + movie_id BIGINT NOT NULL, + date TIMESTAMP NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (room_id) REFERENCES room (id), + FOREIGN KEY (movie_id) REFERENCES movie (id), +); + +CREATE INDEX idx_screening_room_id ON screening (room_id); +CREATE INDEX idx_screening_movie_id ON screening (movie_id); +CREATE INDEX idx_screening_date ON screening (date); + +-- --------------------------------------------------------------- +-- table: TicketType +-- --------------------------------------------------------------- +CREATE TABLE ticket_type +( + id BIGINT NOT NULL AUTO_INCREMENT, + type_name VARCHAR(50) NOT NULL, + price DECIMAL(6, 2) NOT NULL, + currency VARCHAR(3) NOT NULL, + PRIMARY KEY (id), +); + +-- --------------------------------------------------------------- +-- table: Reservation +-- --------------------------------------------------------------- +CREATE TABLE reservation +( + id BIGINT NOT NULL AUTO_INCREMENT, + screening_id BIGINT NOT NULL, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + expiration_time TIMESTAMP NOT NULL, + payment DECIMAL(6, 2), + PRIMARY KEY (id), + FOREIGN KEY (screening_id) REFERENCES screening (id), +); + +CREATE INDEX idx_reservation_screening_id ON reservation (screening_id); +CREATE INDEX idx_reservation_lastname_firstname ON reservation (last_name, first_name); + +-- --------------------------------------------------------------- +-- table: Ticket +-- --------------------------------------------------------------- +CREATE TABLE ticket +( + id BIGINT NOT NULL AUTO_INCREMENT, + room_seat_id BIGINT NOT NULL, + screening_id BIGINT NOT NULL, + reservation_id BIGINT NOT NULL, + ticket_type_id BIGINT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (room_seat_id) REFERENCES room_seat (id), + FOREIGN KEY (screening_id) REFERENCES screening (id), + FOREIGN KEY (reservation_id) REFERENCES reservation (id), + FOREIGN KEY (ticket_type_id) REFERENCES ticket_type (id), +); + +CREATE INDEX idx_ticket_room_seat_id ON ticket (screening_id); +CREATE INDEX idx_ticket_screening_id ON ticket (screening_id); +CREATE INDEX idx_ticket_reservation_id ON ticket (screening_id); +CREATE INDEX idx_ticket_ticket_type_id ON ticket (screening_id); \ No newline at end of file diff --git a/src/test/java/com/mkopec/ticketbookingapp/service/SeatValidationServiceTest.java b/src/test/java/com/mkopec/ticketbookingapp/service/SeatValidationServiceTest.java new file mode 100644 index 0000000..eacf9f6 --- /dev/null +++ b/src/test/java/com/mkopec/ticketbookingapp/service/SeatValidationServiceTest.java @@ -0,0 +1,147 @@ +package com.mkopec.ticketbookingapp.service; + +import com.mkopec.ticketbookingapp.domain.RoomSeat; +import com.mkopec.ticketbookingapp.domain.Seat; +import com.mkopec.ticketbookingapp.exception.ArgumentNotValidException; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.junit.runners.Parameterized.Parameters; + +@RunWith(Enclosed.class) +public class SeatValidationServiceTest { + + private static List getRoomSeats() { + List rs = new ArrayList<>(); + rs.add(createRoomSeat(1L, "A", 1, true)); + rs.add(createRoomSeat(2L, "A", 2, false)); + rs.add(createRoomSeat(3L, "A", 3, false)); + rs.add(createRoomSeat(4L, "A", 4, false)); + rs.add(createRoomSeat(5L, "A", 5, false)); + rs.add(createRoomSeat(6L, "A", 6, true)); + return rs; + } + + private static RoomSeat createRoomSeat(Long id, String row, Integer number, boolean onEdge) { + Seat seat = new Seat(); + seat.setId(id); + seat.setRow(row); + seat.setNumber(number); + + RoomSeat roomSeat = new RoomSeat(); + roomSeat.setId(id); + roomSeat.setSeat(seat); + roomSeat.setIsOnEdge(onEdge); + return roomSeat; + } + + private static List getElements(List seats, int... indexes) { + List result = new ArrayList<>(indexes.length); + for (int i : indexes) { + result.add(seats.get(i)); + } + return result; + } + + + @RunWith(Parameterized.class) + public static class GoodReservationTest { + private SeatValidationService service; + + @Parameter(value = 0) + public List reserved; + + @Parameter(value = 1) + public List occupied; + + @Parameter(value = 2) + public List all; + + @Parameters + public static Collection data() { + List rs = getRoomSeats(); + + return Arrays.asList(new Object[][]{ + // reserved ------ occupied -------- all seats + {getElements(rs, 3), getElements(rs, 0), rs}, + {getElements(rs, 1, 2, 3), getElements(rs, 0, 4, 5), rs}, + {getElements(rs, 1, 2), getElements(rs, 0, 4, 5), rs}, + {getElements(rs, 2), getElements(rs, 0, 3), rs}, + {getElements(rs, 3), new ArrayList(), rs}, + {rs, new ArrayList(), rs}, + {getElements(rs, 1, 2, 3, 4), getElements(rs, 0, 5), rs} + }); + } + + @Before + public void setUp() { + service = new SeatValidationService(); + } + + + @Test + public void test_validate_good() { + assertTrue(service.validate(reserved, occupied, all)); + } + } + + @RunWith(Parameterized.class) + public static class BadReservationTest { + private SeatValidationService service; + + @Parameter(value = 0) + public List reserved; + + @Parameter(value = 1) + public List occupied; + + @Parameter(value = 2) + public List all; + + @Parameters + public static Collection data() { + List rs = getRoomSeats(); + + return Arrays.asList(new Object[][]{ + // reserved ------ occupied -------- all seats + {getElements(rs, 2), getElements(rs, 0), rs}, + {getElements(rs, 2), getElements(rs, 0, 4), rs}, + }); + } + + @Before + public void setUp() { + service = new SeatValidationService(); + } + + @Test(expected = ArgumentNotValidException.class) + public void test_validate_bad() { + service.validate(reserved, occupied, all); + } + } + + public static class OccupiedSeatReservationTest { + private SeatValidationService service; + + @Before + public void setUp() { + service = new SeatValidationService(); + } + + @Test(expected = ArgumentNotValidException.class) + public void test_validate_occupied_seat_throw_exception() { + List rs = getRoomSeats(); + service.validate(getElements(rs, 1), getElements(rs, 0, 1), rs); + } + } +} \ No newline at end of file