| | 1 | = Advanced Application Development |
| | 2 | == Transactional |
| | 3 | === Getting similar listings based on liked ones |
| | 4 | {{{ |
| | 5 | @Transactional(readOnly = true) |
| | 6 | public List<RecommendedListingProjection> getRecommendedListings(Long userId) { |
| | 7 | return recommendationRepository.getRecommendedListings(userId); |
| | 8 | } |
| | 9 | }}} |
| | 10 | === Getting the top 10 most active users |
| | 11 | {{{ |
| | 12 | @Transactional(readOnly = true) |
| | 13 | public List<UserActivityRankingProjection> getTopActiveUsers( |
| | 14 | LocalDateTime startTs, |
| | 15 | LocalDateTime endTs |
| | 16 | ) { |
| | 17 | return analyticsRepository.getTopActiveUsers(startTs, endTs); |
| | 18 | } |
| | 19 | }}} |
| | 20 | === Adding a listing as a favorite |
| | 21 | {{{ |
| | 22 | @Transactional |
| | 23 | public void addFavorite(Long userId, Long listingId) { |
| | 24 | Client client = clientRepository.findByUserId(userId) |
| | 25 | .orElseThrow(() -> new RuntimeException("Client not found")); |
| | 26 | |
| | 27 | Listing listing = listingRepository.findById(listingId) |
| | 28 | .orElseThrow(() -> new RuntimeException("Listing not found")); |
| | 29 | |
| | 30 | FavoriteListing favorite = new FavoriteListing(client, listing); |
| | 31 | favoriteRepository.save(favorite); |
| | 32 | logger.info("Added favorite - User: {}, Listing: {}", userId, listingId); |
| | 33 | } |
| | 34 | }}} |
| | 35 | === Removing a listing from favorites |
| | 36 | {{{ |
| | 37 | @Transactional |
| | 38 | public void removeFavorite(Long userId, Long listingId) { |
| | 39 | Client client = clientRepository.findByUserId(userId) |
| | 40 | .orElseThrow(() -> new RuntimeException("Client not found")); |
| | 41 | |
| | 42 | Listing listing = listingRepository.findById(listingId) |
| | 43 | .orElseThrow(() -> new RuntimeException("Listing not found")); |
| | 44 | |
| | 45 | FavoriteListing favorite = favoriteRepository.findByClientAndListing(client, listing) |
| | 46 | .orElseThrow(() -> new RuntimeException("Favorite not found")); |
| | 47 | |
| | 48 | favoriteRepository.delete(favorite); |
| | 49 | logger.info("Removed favorite - User: {}, Listing: {}", userId, listingId); |
| | 50 | } |
| | 51 | }}} |
| | 52 | === Getting information from the favorite_listings table |
| | 53 | {{{ |
| | 54 | @Transactional(readOnly = true) |
| | 55 | public List<ListingDTO> getFavoritedListings(Long userId) { |
| | 56 | return favoriteRepository.findFavoritedListingDTOs(userId); |
| | 57 | } |
| | 58 | |
| | 59 | @Transactional(readOnly = true) |
| | 60 | public boolean isFavorited(Long userId, Long listingId) { |
| | 61 | Client client = clientRepository.findByUserId(userId) |
| | 62 | .orElse(null); |
| | 63 | |
| | 64 | if (client == null) return false; |
| | 65 | |
| | 66 | Listing listing = listingRepository.findById(listingId) |
| | 67 | .orElse(null); |
| | 68 | |
| | 69 | if (listing == null) return false; |
| | 70 | |
| | 71 | return favoriteRepository.findByClientAndListing(client, listing).isPresent(); |
| | 72 | } |
| | 73 | }}} |
| | 74 | === Creating a review |
| | 75 | {{{ |
| | 76 | @Transactional |
| | 77 | public ReviewDTO createReview(Long reviewerId, Long targetUserId, CreateReviewRequest request) { |
| | 78 | logger.info("createReview"); |
| | 79 | |
| | 80 | if (request.getRating() == null || request.getRating() < 1 || request.getRating() > 5) { |
| | 81 | logger.error(" VALIDATION FAILED: Invalid rating: {}", request.getRating()); |
| | 82 | throw new RuntimeException("Rating must be between 1 and 5"); |
| | 83 | } |
| | 84 | |
| | 85 | |
| | 86 | // Check if reviewer exists |
| | 87 | User reviewer = userRepository.findById(reviewerId) |
| | 88 | .orElseThrow(() -> { |
| | 89 | logger.error(" Reviewer not found with ID: {}", reviewerId); |
| | 90 | return new RuntimeException("Reviewer not found"); |
| | 91 | }); |
| | 92 | |
| | 93 | // Check if target user exists |
| | 94 | User targetUser = userRepository.findById(targetUserId) |
| | 95 | .orElseThrow(() -> { |
| | 96 | logger.error(" Target user not found with ID: {}", targetUserId); |
| | 97 | return new RuntimeException("Target user not found"); |
| | 98 | }); |
| | 99 | |
| | 100 | // Check if review already exists and is not deleted |
| | 101 | logger.info("Checking if reviewer {} has already reviewed user {}", reviewerId, targetUserId); |
| | 102 | var existingReview = userReviewRepository.findTopByReviewReviewerUserIdAndTargetUserIdAndReviewIsDeletedFalseOrderByReviewCreatedAtDesc(reviewerId, targetUserId); |
| | 103 | |
| | 104 | if (existingReview.isPresent()) { |
| | 105 | Review existingReviewEntity = existingReview.get().getReview(); |
| | 106 | logger.info("Found existing review with ID: {}", existingReviewEntity.getReviewId()); |
| | 107 | |
| | 108 | if (!existingReviewEntity.getIsDeleted()) { |
| | 109 | logger.error(" User {} has already reviewed user {} and review is NOT deleted", reviewerId, targetUserId); |
| | 110 | throw new RuntimeException("You have already reviewed this user"); |
| | 111 | } else { |
| | 112 | logger.info(" User {} has a deleted review for user {} - can create a new one", reviewerId, targetUserId); |
| | 113 | } |
| | 114 | } else { |
| | 115 | logger.info(" No existing review found - safe to create new review"); |
| | 116 | } |
| | 117 | |
| | 118 | // Create Review entity |
| | 119 | Review review = new Review(reviewer, request.getRating(), request.getComment()); |
| | 120 | |
| | 121 | |
| | 122 | // Save Review to database with flush |
| | 123 | review = reviewRepository.saveAndFlush(review); |
| | 124 | logger.info("Review ID after save: {}", review.getReviewId()); |
| | 125 | |
| | 126 | if (review.getReviewId() == null) { |
| | 127 | logger.error(" CRITICAL: Review ID is NULL after save!"); |
| | 128 | throw new RuntimeException("Failed to save review - ID is null"); |
| | 129 | } |
| | 130 | |
| | 131 | // Create UserReview entry |
| | 132 | UserReview userReview = new UserReview(); |
| | 133 | userReview.setReview(review); |
| | 134 | logger.info("UserReview reviewId after setReview: {}", userReview.getReviewId()); |
| | 135 | |
| | 136 | userReview.setTargetUserId(targetUserId); |
| | 137 | |
| | 138 | // Save UserReview to database with flush |
| | 139 | userReview = userReviewRepository.saveAndFlush(userReview); |
| | 140 | logger.info(" UserReview saved successfully"); |
| | 141 | |
| | 142 | // Create and return DTO |
| | 143 | ReviewDTO reviewDTO = new ReviewDTO(review); |
| | 144 | logger.info(" ReviewDTO created successfully"); |
| | 145 | |
| | 146 | return reviewDTO; |
| | 147 | } |
| | 148 | }}} |
| | 149 | === Deleting a review |
| | 150 | {{{ |
| | 151 | @Transactional |
| | 152 | public void deleteReview(Long reviewId, Long userId) { |
| | 153 | logger.info("=== START deleteReview (SOFT DELETE) ==="); |
| | 154 | |
| | 155 | |
| | 156 | // Fetch review |
| | 157 | logger.info("Fetching review with ID: {}", reviewId); |
| | 158 | Review review = reviewRepository.findById(reviewId) |
| | 159 | .orElseThrow(() -> { |
| | 160 | logger.error(" Review not found with ID: {}", reviewId); |
| | 161 | return new RuntimeException("Review not found"); |
| | 162 | }); |
| | 163 | logger.info(" Review found: reviewer={}, rating={}", review.getReviewer().getUsername(), review.getRating()); |
| | 164 | |
| | 165 | // Check authorization |
| | 166 | if (!review.getReviewer().getUserId().equals(userId)) { |
| | 167 | logger.error(" User {} is not authorized to delete review {}. Reviewer is {}", userId, reviewId, review.getReviewer().getUserId()); |
| | 168 | throw new RuntimeException("You can only delete your own reviews"); |
| | 169 | } |
| | 170 | |
| | 171 | // Soft delete: mark as deleted instead of physically removing |
| | 172 | review.setIsDeleted(true); |
| | 173 | review.setUpdatedAt(LocalDateTime.now()); |
| | 174 | reviewRepository.save(review); |
| | 175 | |
| | 176 | logger.info("=== END deleteReview - SUCCESS ==="); |
| | 177 | } |
| | 178 | }}} |
| | 179 | === Creating a new pet |
| | 180 | {{{ |
| | 181 | @Transactional |
| | 182 | public AnimalResponseDTO addPet(Long userId,@RequestBody CreatePetRequest request) { |
| | 183 | |
| | 184 | // Validate required fields |
| | 185 | if (request.getName() == null || request.getName().isBlank()) { |
| | 186 | throw new RuntimeException("Pet name is required"); |
| | 187 | } |
| | 188 | |
| | 189 | if (request.getSex() == null || request.getSex().isBlank()) { |
| | 190 | throw new RuntimeException("Pet sex is required"); |
| | 191 | } |
| | 192 | |
| | 193 | if (request.getType() == null || request.getType().isBlank()) { |
| | 194 | throw new RuntimeException("Pet type is required"); |
| | 195 | } |
| | 196 | |
| | 197 | |
| | 198 | if (request.getSpecies() == null || request.getSpecies().isBlank()) { |
| | 199 | throw new RuntimeException("Pet species is required"); |
| | 200 | } |
| | 201 | |
| | 202 | // Get user |
| | 203 | User user = userRepository.findById(userId) |
| | 204 | .orElseThrow(() -> { |
| | 205 | logger.error(" User not found with ID: {}", userId); |
| | 206 | return new RuntimeException("User not found"); |
| | 207 | }); |
| | 208 | |
| | 209 | logger.info("Adding pet for user ID: {}", userId); |
| | 210 | |
| | 211 | // Check if user is already an owner, if not, promote them |
| | 212 | Owner owner = ownerRepository.findByUserId(userId) |
| | 213 | .orElseGet(() -> { |
| | 214 | logger.info("⚠User {} is a CLIENT, promoting to OWNER", userId); |
| | 215 | Owner newOwner = new Owner(user); |
| | 216 | Owner savedOwner = ownerRepository.save(newOwner); |
| | 217 | logger.info(" User promoted to OWNER with ID: {}", savedOwner.getUserId()); |
| | 218 | return savedOwner; |
| | 219 | }); |
| | 220 | |
| | 221 | // Create new pet with all schema fields |
| | 222 | |
| | 223 | Pet pet = new Pet( |
| | 224 | request.getName(), |
| | 225 | request.getSex(), |
| | 226 | request.getDateOfBirth(), |
| | 227 | request.getPhotoUrl(), |
| | 228 | request.getType(), |
| | 229 | request.getSpecies(), |
| | 230 | request.getBreed(), |
| | 231 | request.getLocatedName(), |
| | 232 | owner |
| | 233 | ); |
| | 234 | |
| | 235 | Pet savedPet = petRepository.save(pet); |
| | 236 | logger.info("Pet ID: {}, Owner ID: {}, Name: {}", |
| | 237 | savedPet.getAnimalId(), userId, savedPet.getName()); |
| | 238 | |
| | 239 | AnimalResponseDTO result = new AnimalResponseDTO(savedPet); |
| | 240 | |
| | 241 | |
| | 242 | return result; |
| | 243 | } |
| | 244 | }}} |
| | 245 | === Creating a new listing |
| | 246 | {{{ |
| | 247 | @Transactional |
| | 248 | public ListingDTO createListing(Long userId, CreateListingRequest request) { |
| | 249 | // Check if user is an owner |
| | 250 | Owner owner = ownerRepository.findByUserId(userId) |
| | 251 | .orElseThrow(() -> new RuntimeException("User is not an owner. Only owners can create listings.")); |
| | 252 | |
| | 253 | logger.info("Creating listing for owner ID: {}", userId); |
| | 254 | |
| | 255 | // Create new listing with Owner object |
| | 256 | Listing listing = new Listing( |
| | 257 | owner, |
| | 258 | request.getAnimalId(), |
| | 259 | request.getPrice(), |
| | 260 | request.getDescription() |
| | 261 | ); |
| | 262 | |
| | 263 | Listing savedListing = listingRepository.save(listing); |
| | 264 | |
| | 265 | logger.info("Listing created successfully - ID: {}, Owner ID: {}, Animal ID: {}", |
| | 266 | savedListing.getListingId(), userId, request.getAnimalId()); |
| | 267 | |
| | 268 | return mapToDTO(savedListing); |
| | 269 | } |
| | 270 | }}} |
| | 271 | == Pooling |
| | 272 | In the backend layer of the Petify application, we use Spring Boot with Spring Data JPA to communicate with the PostgreSQL database. Because of this architecture, database connections are not created manually. Instead, they are automatically managed by HikariCP, which is the default connection pool implementation in Spring Boot. |
| | 273 | [[BR]] |
| | 274 | HikariCP is included transitively through: |
| | 275 | {{{spring-boot-starter-data-jpa}}} |
| | 276 | [[BR]] |
| | 277 | Therefore, no additional configuration is required to enable pooling. |
| | 278 | [[BR]] |
| | 279 | In this project, we use these HikariCP configuration values: |
| | 280 | {{{ |
| | 281 | spring.datasource.hikari.maximum-pool-size=20 |
| | 282 | spring.datasource.hikari.minimum-idle=5 |
| | 283 | spring.datasource.hikari.connection-timeout=20000 |
| | 284 | spring.datasource.hikari.idle-timeout=300000 |
| | 285 | spring.datasource.hikari.max-lifetime=1200000 |
| | 286 | spring.datasource.hikari.auto-commit=true |
| | 287 | }}} |
| | 288 | These values mean: |
| | 289 | * A maximum of 20 database connections can be active at the same time. |
| | 290 | * The pool maintains 5 idle connections ready for immediate use. |
| | 291 | * If all connections are busy, a request waits up to 20 seconds before failing. |
| | 292 | * Idle connections are kept alive for up to 5 minutes. |
| | 293 | * Connections are refreshed every 20 minutes to avoid stale connections. |
| | 294 | * Auto-commit is enabled by default. |
| | 295 | {{{ |
| | 296 | 2026-03-09T23:42:34.869+01:00 DEBUG 37988 --- [l-1:housekeeper] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Before cleanup stats (total=1/20, idle=1/5, active=0, waiting=0) |
| | 297 | 2026-03-09T23:42:34.869+01:00 DEBUG 37988 --- [l-1:housekeeper] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - After cleanup stats (total=1/20, idle=1/5, active=0, waiting=0) |
| | 298 | 2026-03-09T23:42:34.870+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Attempting to create/setup new connection (3f4fb556-9cc3-4220-9556-e35534a5a863) |
| | 299 | 2026-03-09T23:42:34.891+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Established new connection (3f4fb556-9cc3-4220-9556-e35534a5a863) |
| | 300 | 2026-03-09T23:42:34.891+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@40ee8801 |
| | 301 | 2026-03-09T23:42:34.930+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - After adding stats (total=2/20, idle=2/5, active=0, waiting=0) |
| | 302 | 2026-03-09T23:42:34.930+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Attempting to create/setup new connection (148676f5-024a-4b14-aed0-8d0727b8def1) |
| | 303 | 2026-03-09T23:42:34.950+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Established new connection (148676f5-024a-4b14-aed0-8d0727b8def1) |
| | 304 | 2026-03-09T23:42:34.950+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@4e297e6c |
| | 305 | 2026-03-09T23:42:34.992+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - After adding stats (total=3/20, idle=3/5, active=0, waiting=0) |
| | 306 | 2026-03-09T23:42:34.992+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Attempting to create/setup new connection (f85f6fca-da39-4e54-bdff-e29c851df6cf) |
| | 307 | 2026-03-09T23:42:35.014+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Established new connection (f85f6fca-da39-4e54-bdff-e29c851df6cf) |
| | 308 | 2026-03-09T23:42:35.015+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@4a16e371 |
| | 309 | 2026-03-09T23:42:35.054+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - After adding stats (total=4/20, idle=4/5, active=0, waiting=0) |
| | 310 | 2026-03-09T23:42:35.054+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Attempting to create/setup new connection (1555b87b-99a2-405d-a1dc-2d65ab6e44ed) |
| | 311 | 2026-03-09T23:42:35.076+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Established new connection (1555b87b-99a2-405d-a1dc-2d65ab6e44ed) |
| | 312 | 2026-03-09T23:42:35.077+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@f147457 |
| | 313 | 2026-03-09T23:42:35.115+01:00 DEBUG 37988 --- [onnection-adder] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - After adding stats (total=5/20, idle=5/5, active=0, waiting=0) |
| | 314 | }}} |