├── .sdkmanrc ├── gatling-tests ├── src │ └── test │ │ ├── resources │ │ ├── application.conf │ │ ├── data │ │ │ └── feeders │ │ │ │ ├── credentials.csv │ │ │ │ ├── categories.csv │ │ │ │ └── posts.csv │ │ ├── gatling-akka.conf │ │ └── logback-test.xml │ │ └── java │ │ ├── utils │ │ └── SimulationHelper.java │ │ └── techbuzz │ │ ├── PostsBrowsingSimulation.java │ │ └── PostCreationSimulation.java ├── .gitignore ├── .mvn │ └── wrapper │ │ └── maven-wrapper.properties └── pom.xml ├── techbuzz ├── src │ ├── main │ │ ├── resources │ │ │ ├── db │ │ │ │ └── migration │ │ │ │ │ ├── V3__modify_user_add_column.sql │ │ │ │ │ ├── V2__insert_user_categories.sql │ │ │ │ │ └── V1__create_tables.sql │ │ │ ├── static │ │ │ │ ├── favicon.ico │ │ │ │ └── assets │ │ │ │ │ ├── images │ │ │ │ │ ├── go.png │ │ │ │ │ ├── career.png │ │ │ │ │ ├── cloud.png │ │ │ │ │ ├── devops.png │ │ │ │ │ ├── java.png │ │ │ │ │ ├── nodejs.png │ │ │ │ │ ├── python.png │ │ │ │ │ ├── webdev.png │ │ │ │ │ ├── general.png │ │ │ │ │ └── testing.png │ │ │ │ │ ├── js │ │ │ │ │ ├── common.js │ │ │ │ │ └── app.js │ │ │ │ │ └── css │ │ │ │ │ └── styles.css │ │ │ ├── application-local.properties │ │ │ ├── templates │ │ │ │ ├── error │ │ │ │ │ ├── 403.html │ │ │ │ │ └── 404.html │ │ │ │ ├── error.html │ │ │ │ ├── users │ │ │ │ │ ├── registrationStatus.html │ │ │ │ │ ├── emailVerification.html │ │ │ │ │ ├── forgotPassword.html │ │ │ │ │ ├── resendVerification.html │ │ │ │ │ ├── login.html │ │ │ │ │ ├── profile.html │ │ │ │ │ └── resetPassword.html │ │ │ │ ├── fragments │ │ │ │ │ ├── user-posts.html │ │ │ │ │ ├── pagination.html │ │ │ │ │ ├── minimal-post.html │ │ │ │ │ ├── pagination-in-tabs.html │ │ │ │ │ ├── view-only-post.html │ │ │ │ │ └── post.html │ │ │ │ ├── posts │ │ │ │ │ ├── home.html │ │ │ │ │ └── category.html │ │ │ │ └── email │ │ │ │ │ ├── new-posts-email.html │ │ │ │ │ ├── verify-email.html │ │ │ │ │ └── password-reset.html │ │ │ └── application.properties │ │ ├── java │ │ │ └── com │ │ │ │ └── sivalabs │ │ │ │ └── techbuzz │ │ │ │ ├── users │ │ │ │ ├── domain │ │ │ │ │ ├── models │ │ │ │ │ │ ├── RoleEnum.java │ │ │ │ │ │ ├── UserProfile.java │ │ │ │ │ │ └── User.java │ │ │ │ │ ├── dtos │ │ │ │ │ │ ├── ForgotPasswordRequest.java │ │ │ │ │ │ ├── ResendVerificationRequest.java │ │ │ │ │ │ ├── UserDTO.java │ │ │ │ │ │ ├── PasswordResetRequest.java │ │ │ │ │ │ └── CreateUserRequest.java │ │ │ │ │ ├── mappers │ │ │ │ │ │ └── UserDTOMapper.java │ │ │ │ │ └── repositories │ │ │ │ │ │ └── UserRepository.java │ │ │ │ └── web │ │ │ │ │ └── controllers │ │ │ │ │ ├── LoginController.java │ │ │ │ │ ├── EmailVerificationController.java │ │ │ │ │ ├── ProfileController.java │ │ │ │ │ ├── PasswordResetController.java │ │ │ │ │ └── ForgotPasswordController.java │ │ │ │ ├── posts │ │ │ │ ├── domain │ │ │ │ │ ├── dtos │ │ │ │ │ │ ├── CreateVoteRequest.java │ │ │ │ │ │ ├── UpdatePostRequest.java │ │ │ │ │ │ ├── CreatePostRequest.java │ │ │ │ │ │ └── PostViewDTO.java │ │ │ │ │ ├── repositories │ │ │ │ │ │ ├── CategoryRepository.java │ │ │ │ │ │ ├── VoteRepository.java │ │ │ │ │ │ └── PostRepository.java │ │ │ │ │ ├── services │ │ │ │ │ │ └── CategoryService.java │ │ │ │ │ ├── mappers │ │ │ │ │ │ └── PostMapper.java │ │ │ │ │ └── models │ │ │ │ │ │ ├── Vote.java │ │ │ │ │ │ └── Category.java │ │ │ │ ├── web │ │ │ │ │ └── controllers │ │ │ │ │ │ ├── HomeController.java │ │ │ │ │ │ ├── GetCategoriesController.java │ │ │ │ │ │ ├── AddVoteController.java │ │ │ │ │ │ ├── PostsByUserController.java │ │ │ │ │ │ ├── DeletePostController.java │ │ │ │ │ │ ├── ViewCategoryController.java │ │ │ │ │ │ └── CreatePostController.java │ │ │ │ ├── jobs │ │ │ │ │ └── NewPostsNotificationJob.java │ │ │ │ └── adapter │ │ │ │ │ └── repositories │ │ │ │ │ ├── JooqCategoryRepository.java │ │ │ │ │ └── JooqVoteRepository.java │ │ │ │ ├── notifications │ │ │ │ ├── EmailService.java │ │ │ │ └── JavaEmailService.java │ │ │ │ ├── common │ │ │ │ ├── exceptions │ │ │ │ │ ├── TechBuzzException.java │ │ │ │ │ ├── ResourceNotFoundException.java │ │ │ │ │ ├── ResourceAlreadyExistsException.java │ │ │ │ │ └── UnauthorisedAccessException.java │ │ │ │ └── model │ │ │ │ │ ├── SystemClock.java │ │ │ │ │ └── PagedResult.java │ │ │ │ ├── config │ │ │ │ ├── logging │ │ │ │ │ ├── Loggable.java │ │ │ │ │ └── LoggingAspect.java │ │ │ │ ├── annotations │ │ │ │ │ ├── CurrentUser.java │ │ │ │ │ ├── AdminOnly.java │ │ │ │ │ └── AnyAuthenticatedUser.java │ │ │ │ ├── AppConfig.java │ │ │ │ ├── WebMvcConfig.java │ │ │ │ └── argresolvers │ │ │ │ │ └── CurrentUserArgumentResolver.java │ │ │ │ ├── TechBuzzApplication.java │ │ │ │ ├── ApplicationProperties.java │ │ │ │ └── security │ │ │ │ ├── SecurityUser.java │ │ │ │ ├── SecurityUserDetailsService.java │ │ │ │ ├── SecurityService.java │ │ │ │ └── WebSecurityConfig.java │ │ └── jooq │ │ │ └── com │ │ │ └── sivalabs │ │ │ └── techbuzz │ │ │ └── jooq │ │ │ ├── Tables.java │ │ │ ├── Sequences.java │ │ │ ├── DefaultCatalog.java │ │ │ └── Public.java │ └── test │ │ ├── resources │ │ ├── application-test.properties │ │ ├── users_with_reset_password_token.sql │ │ ├── db │ │ │ └── migration │ │ │ │ └── test │ │ │ │ └── V9999__insert_test_data.sql │ │ ├── logback-test.xml │ │ ├── posts_created_before_a_month.sql │ │ └── posts_with_mixed_date_range.sql │ │ └── java │ │ └── com │ │ └── sivalabs │ │ └── techbuzz │ │ ├── TestTechBuzzApplication.java │ │ ├── TechBuzzApplicationTests.java │ │ ├── posts │ │ ├── web │ │ │ └── controllers │ │ │ │ ├── GetCategoriesControllerTest.java │ │ │ │ ├── HomeControllerTest.java │ │ │ │ ├── ViewCategoryControllerTests.java │ │ │ │ ├── DeletePostControllerTests.java │ │ │ │ ├── PostsByUserControllerTest.java │ │ │ │ ├── AddVoteControllerTest.java │ │ │ │ └── CreatePostControllerTest.java │ │ ├── domain │ │ │ ├── models │ │ │ │ └── PostTest.java │ │ │ └── mappers │ │ │ │ └── PostMapperTest.java │ │ └── jobs │ │ │ └── NewPostNotificationJobTest.java │ │ ├── users │ │ └── web │ │ │ └── controllers │ │ │ ├── ProfileControllerTest.java │ │ │ ├── LoginControllerTest.java │ │ │ ├── ForgotPasswordControllerTest.java │ │ │ ├── EmailVerificationControllerTest.java │ │ │ └── RegistrationControllerTests.java │ │ ├── common │ │ └── AbstractIntegrationTest.java │ │ ├── TestcontainersConfig.java │ │ └── ArchUnitTest.java ├── sonar-project.properties ├── .gitignore └── .mvn │ └── wrapper │ └── maven-wrapper.properties ├── .gitpod.yml ├── e2e-tests ├── .gitignore ├── src │ └── test │ │ ├── resources │ │ ├── config.json │ │ └── dev.json │ │ └── java │ │ └── com │ │ └── sivalabs │ │ └── techbuzz │ │ ├── config │ │ ├── ConfigLoader.java │ │ └── Configuration.java │ │ ├── PostsBrowsingTests.java │ │ ├── BaseTest.java │ │ └── AuthenticatedUserActionsTests.java └── .mvn │ └── wrapper │ └── maven-wrapper.properties ├── .gitignore ├── .gitpod.Dockerfile ├── renovate.json ├── deployment └── docker-compose │ ├── .env │ └── docker-compose.yml ├── .mvn ├── jvm.config └── wrapper │ └── maven-wrapper.properties ├── adr ├── ui-tech-selection.md └── persistence-library-selection.md ├── run.sh ├── Taskfile.yml ├── pom.xml ├── .github └── workflows │ └── maven.yml └── .devcontainer └── devcontainer.json /.sdkmanrc: -------------------------------------------------------------------------------- 1 | java=21.0.1-tem 2 | maven=3.9.9 3 | -------------------------------------------------------------------------------- /gatling-tests/src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | app { 2 | baseUrl = "http://localhost:8080" 3 | } 4 | 5 | users = 10 6 | -------------------------------------------------------------------------------- /gatling-tests/src/test/resources/data/feeders/credentials.csv: -------------------------------------------------------------------------------- 1 | username,password 2 | admin@gmail.com,admin 3 | user@gmail.com,user 4 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/db/migration/V3__modify_user_add_column.sql: -------------------------------------------------------------------------------- 1 | alter table users 2 | add column password_reset_token varchar; -------------------------------------------------------------------------------- /techbuzz/src/test/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | spring.flyway.locations=classpath:/db/migration,classpath:/db/migration/test 2 | -------------------------------------------------------------------------------- /techbuzz/src/test/resources/users_with_reset_password_token.sql: -------------------------------------------------------------------------------- 1 | update users set password_reset_token='test' where users.email='admin@gmail.com'; -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | 4 | tasks: 5 | - name: Build application 6 | init: ./mvnw -B dependency:go-offline package -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/favicon.ico -------------------------------------------------------------------------------- /gatling-tests/src/test/resources/data/feeders/categories.csv: -------------------------------------------------------------------------------- 1 | category 2 | java 3 | webdev 4 | go 5 | python 6 | devops 7 | testing 8 | career 9 | general 10 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/go.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/go.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/career.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/career.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/cloud.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/devops.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/devops.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/java.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/java.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/nodejs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/nodejs.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/python.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/webdev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/webdev.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/general.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/general.png -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/images/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/techbuzz/HEAD/techbuzz/src/main/resources/static/assets/images/testing.png -------------------------------------------------------------------------------- /e2e-tests/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | src/test/resources/local.json 3 | 4 | ### IntelliJ IDEA ### 5 | .idea 6 | *.iws 7 | *.iml 8 | *.ipr 9 | 10 | ### VS Code ### 11 | .vscode/ 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | *.ipr 4 | target/ 5 | logs/ 6 | ### VS Code ### 7 | .vscode/ 8 | 9 | docker/db-data/ 10 | deployment/docker-compose/.env.prod 11 | docker/.env.aws 12 | .pom.xml.temp 13 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | USER gitpod 4 | 5 | RUN bash -c ". /home/gitpod/.sdkman/bin/sdkman-init.sh && \ 6 | sdk install java 21.0.1-tem && \ 7 | sdk default java 21.0.1-tem" -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/models/RoleEnum.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.models; 2 | 3 | public enum RoleEnum { 4 | ROLE_ADMIN, 5 | ROLE_MODERATOR, 6 | ROLE_USER 7 | } 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "schedule": [ 7 | "before 4am on the first day of the month" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /deployment/docker-compose/.env: -------------------------------------------------------------------------------- 1 | DB_HOST=techbuzz-db 2 | DB_PORT=5432 3 | DB_DATABASE=postgres 4 | DB_USERNAME=postgres 5 | DB_PASSWORD=postgres 6 | 7 | EMAIL_PROVIDER=java 8 | MAIL_HOST=mailhog 9 | MAIL_PORT=1025 10 | SENDGRID_API_KEY= 11 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/js/common.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') 3 | const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) 4 | }); 5 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/dtos/CreateVoteRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.dtos; 2 | 3 | import jakarta.validation.constraints.NotNull; 4 | 5 | public record CreateVoteRequest(@NotNull Long postId, Long userId, @NotNull Integer value) {} 6 | -------------------------------------------------------------------------------- /gatling-tests/src/test/resources/gatling-akka.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | #loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | #logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 4 | #log-dead-letters = off 5 | actor { 6 | default-dispatcher { 7 | #throughput = 20 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /e2e-tests/src/test/resources/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootUrl": "http://localhost:8080", 3 | "headlessMode": false, 4 | "slowMo": 1000, 5 | "adminUserEmail": "admin@gmail.com", 6 | "adminUserPassword": "admin", 7 | "normalUserEmail": "user@gmail.com", 8 | "normalUserPassword": "user" 9 | } 10 | -------------------------------------------------------------------------------- /e2e-tests/src/test/resources/dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootUrl": "http://localhost:18080", 3 | "headlessMode": true, 4 | "slowMo": 1000, 5 | "adminUserEmail": "admin@gmail.com", 6 | "adminUserPassword": "admin", 7 | "normalUserEmail": "user@gmail.com", 8 | "normalUserPassword": "user" 9 | } 10 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/application-local.properties: -------------------------------------------------------------------------------- 1 | logging.level.org.jooq.tools.LoggerListener=DEBUG 2 | 3 | #spring.flyway.clean-on-validation-error=true 4 | #spring.flyway.clean-disabled=false 5 | 6 | EMAIL_PROVIDER=java 7 | SENDGRID_API_KEY= 8 | MAIL_HOST=127.0.0.1 9 | MAIL_PORT=1025 10 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #FBFBFB; 3 | } 4 | #app { 5 | margin-top: 70px; 6 | } 7 | 8 | a:link { 9 | text-decoration: none; 10 | } 11 | 12 | .category-tile { 13 | padding: 10px; 14 | } 15 | 16 | .category-img { 17 | padding: 2px; 18 | width: 140px; 19 | height: 95px; 20 | } -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/dtos/ForgotPasswordRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.dtos; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record ForgotPasswordRequest( 7 | @NotBlank(message = "Email cannot be blank") @Email(message = "Invalid email address") String email) {} 8 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/dtos/ResendVerificationRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.dtos; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record ResendVerificationRequest( 7 | @NotBlank(message = "Email cannot be blank") @Email(message = "Invalid email address") String email) {} 8 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/notifications/EmailService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.notifications; 2 | 3 | import java.util.Map; 4 | 5 | public interface EmailService { 6 | void sendEmail(String template, Map params, String to, String subject); 7 | 8 | void sendBroadcastEmail(String template, Map params, String to, String subject); 9 | } 10 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/common/exceptions/TechBuzzException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common.exceptions; 2 | 3 | public class TechBuzzException extends RuntimeException { 4 | 5 | public TechBuzzException(String message) { 6 | super(message); 7 | } 8 | 9 | public TechBuzzException(String message, Exception e) { 10 | super(message, e); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/common/model/SystemClock.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common.model; 2 | 3 | import java.time.LocalDateTime; 4 | import java.time.ZoneId; 5 | 6 | public class SystemClock { 7 | public static final ZoneId ZONE_ID = ZoneId.systemDefault(); 8 | 9 | public static LocalDateTime dateTimeNow() { 10 | return LocalDateTime.now(ZONE_ID); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /techbuzz/src/test/resources/db/migration/test/V9999__insert_test_data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO users (email, password, name, role, verified, created_at) 2 | VALUES ('admin@gmail.com', '$2a$10$ZuGgeoawgOg.6AM3QEGZ3O4QlBSWyRx3A70oIcBjYPpUB8mAZWY16', 'Admin', 'ROLE_ADMIN', true, CURRENT_TIMESTAMP), 3 | ('user@gmail.com', '$2a$10$9.asbEZnVSA24cavY2xStO1FQS54WZnxUzSxqYepEoCFYAvUVnVr6', 'Demo', 'ROLE_USER', true, CURRENT_TIMESTAMP) 4 | ; -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/dtos/UserDTO.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.dtos; 2 | 3 | import com.sivalabs.techbuzz.users.domain.models.RoleEnum; 4 | 5 | public record UserDTO( 6 | Long id, 7 | String name, 8 | String email, 9 | RoleEnum role, 10 | boolean verified, 11 | String verificationToken, 12 | String passwordResetToken) {} 13 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/repositories/CategoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.repositories; 2 | 3 | import com.sivalabs.techbuzz.posts.domain.models.Category; 4 | import java.util.List; 5 | import java.util.Optional; 6 | 7 | public interface CategoryRepository { 8 | 9 | Optional findBySlug(String slug); 10 | 11 | Category findById(Long id); 12 | 13 | List findAll(); 14 | } 15 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/TestTechBuzzApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | 5 | public class TestTechBuzzApplication { 6 | public static void main(String[] args) { 7 | TestcontainersConfig.init(); 8 | 9 | SpringApplication.from(TechBuzzApplication::main) 10 | .with(TestcontainersConfig.class) 11 | .run(args); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/common/exceptions/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common.exceptions; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | public class ResourceNotFoundException extends TechBuzzException { 8 | 9 | public ResourceNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/common/exceptions/ResourceAlreadyExistsException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common.exceptions; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.CONFLICT) 7 | public class ResourceAlreadyExistsException extends TechBuzzException { 8 | public ResourceAlreadyExistsException(String message) { 9 | super(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/common/exceptions/UnauthorisedAccessException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common.exceptions; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.FORBIDDEN) 7 | public class UnauthorisedAccessException extends TechBuzzException { 8 | 9 | public UnauthorisedAccessException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/repositories/VoteRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.repositories; 2 | 3 | import com.sivalabs.techbuzz.posts.domain.models.Vote; 4 | import java.util.Optional; 5 | 6 | public interface VoteRepository { 7 | 8 | Optional findByPostIdAndUserId(Long postId, Long userId); 9 | 10 | void deleteVotesForPost(Long postId); 11 | 12 | Vote save(Vote vote); 13 | 14 | void update(Vote vote); 15 | } 16 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/dtos/PasswordResetRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.dtos; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record PasswordResetRequest( 7 | @NotBlank(message = "Email cannot be blank") @Email(message = "Invalid email address") String email, 8 | @NotBlank String token, 9 | @NotBlank(message = "Password cannot be blank") String password) {} 10 | -------------------------------------------------------------------------------- /techbuzz/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/dtos/CreateUserRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.dtos; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record CreateUserRequest( 7 | @NotBlank(message = "Name cannot be blank") String name, 8 | @NotBlank(message = "Email cannot be blank") @Email(message = "Invalid email address") String email, 9 | @NotBlank(message = "Password cannot be blank") String password) {} 10 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/logging/Loggable.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config.logging; 2 | 3 | import static java.lang.annotation.ElementType.METHOD; 4 | import static java.lang.annotation.ElementType.TYPE; 5 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 6 | 7 | import java.lang.annotation.Inherited; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.Target; 10 | 11 | @Target({METHOD, TYPE}) 12 | @Retention(RUNTIME) 13 | @Inherited 14 | public @interface Loggable {} 15 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/annotations/CurrentUser.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config.annotations; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Inherited; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | 10 | @Target({ElementType.PARAMETER}) 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @Inherited 13 | @Documented 14 | public @interface CurrentUser {} 15 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/dtos/UpdatePostRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.dtos; 2 | 3 | import jakarta.validation.constraints.NotEmpty; 4 | import jakarta.validation.constraints.NotNull; 5 | 6 | public record UpdatePostRequest( 7 | Long id, 8 | @NotEmpty(message = "Title should not be blank") String title, 9 | String url, 10 | @NotEmpty(message = "Content should not be blank") String content, 11 | @NotNull(message = "CategoryId should not be blank") Long categoryId) {} 12 | -------------------------------------------------------------------------------- /gatling-tests/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/dtos/CreatePostRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.dtos; 2 | 3 | import jakarta.validation.constraints.NotEmpty; 4 | import jakarta.validation.constraints.NotNull; 5 | 6 | public record CreatePostRequest( 7 | @NotEmpty(message = "Title should not be blank") String title, 8 | String url, 9 | @NotEmpty(message = "Content should not be blank") String content, 10 | @NotNull(message = "CategoryId should not be blank") Long categoryId, 11 | Long createdUserId) {} 12 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/common/model/PagedResult.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import java.util.List; 5 | 6 | public record PagedResult( 7 | List data, 8 | long totalElements, 9 | int pageNumber, 10 | int totalPages, 11 | @JsonProperty("isFirst") boolean isFirst, 12 | @JsonProperty("isLast") boolean isLast, 13 | @JsonProperty("hasNext") boolean hasNext, 14 | @JsonProperty("hasPrevious") boolean hasPrevious) {} 15 | -------------------------------------------------------------------------------- /techbuzz/sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.sourceEncoding=UTF-8 2 | sonar.projectKey=sivaprasadreddy_techbuzz 3 | sonar.projectName=techbuzz 4 | sonar.organization=sivaprasadreddy-github 5 | sonar.host.url=https://sonarcloud.io 6 | 7 | sonar.sources=src/main/java 8 | sonar.tests=src/test/java 9 | sonar.exclusions=src/main/java/com/sivalabs/techbuzz/jooq/** 10 | sonar.test.inclusions=**/*Test.java,**/*Tests.java,**/*IntegrationTest.java,**/*IT.java 11 | sonar.java.codeCoveragePlugin=jacoco 12 | sonar.coverage.jacoco.xmlReportPaths=target/jacoco/test/jacoco.xml 13 | sonar.junit.reportPaths=target/surefire-reports 14 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/error/403.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Access Denied 8 | 9 | 10 |
11 | 12 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /techbuzz/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | src/main/resources/application-supabase.properties 36 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/TechBuzzApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 5 | 6 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 7 | import org.junit.jupiter.api.Test; 8 | 9 | class TechBuzzApplicationTests extends AbstractIntegrationTest { 10 | 11 | @Test 12 | void contextLoads() throws Exception { 13 | mockMvc.perform(get("/actuator/health")).andExpect(status().isOk()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/annotations/AdminOnly.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config.annotations; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Inherited; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | import org.springframework.security.access.prepost.PreAuthorize; 10 | 11 | @Target({ElementType.METHOD, ElementType.TYPE}) 12 | @Retention(RetentionPolicy.RUNTIME) 13 | @Inherited 14 | @Documented 15 | @PreAuthorize("hasRole('ROLE_ADMIN')") 16 | public @interface AdminOnly {} 17 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/error/404.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Page not found 8 | 9 | 10 |
11 | 12 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /.mvn/jvm.config: -------------------------------------------------------------------------------- 1 | --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED 2 | --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED 3 | --add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED 4 | --add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED 5 | --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED 6 | --add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED 7 | --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED 8 | --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED 9 | --add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED 10 | --add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/annotations/AnyAuthenticatedUser.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config.annotations; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Inherited; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | import org.springframework.security.access.prepost.PreAuthorize; 10 | 11 | @Target({ElementType.METHOD, ElementType.TYPE}) 12 | @Retention(RetentionPolicy.RUNTIME) 13 | @Inherited 14 | @Documented 15 | @PreAuthorize("isAuthenticated()") 16 | public @interface AnyAuthenticatedUser {} 17 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Error 9 | 10 | 11 |
12 | 13 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /gatling-tests/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss.SSS} [%-5level] %logger{15} - %msg%n%rEx 7 | 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/TechBuzzApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | import org.springframework.cache.annotation.EnableCaching; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | import org.springframework.scheduling.annotation.EnableScheduling; 9 | 10 | @SpringBootApplication 11 | @ConfigurationPropertiesScan 12 | @EnableAsync 13 | @EnableScheduling 14 | @EnableCaching 15 | public class TechBuzzApplication { 16 | 17 | public static void main(String[] args) { 18 | SpringApplication.run(TechBuzzApplication.class, args); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.Min; 5 | import jakarta.validation.constraints.NotEmpty; 6 | import java.util.List; 7 | import org.springframework.boot.context.properties.ConfigurationProperties; 8 | import org.springframework.validation.annotation.Validated; 9 | 10 | @ConfigurationProperties(prefix = "techbuzz") 11 | @Validated 12 | public record ApplicationProperties( 13 | String emailProvider, 14 | @NotEmpty @Email String adminEmail, 15 | String sendgridApiKey, 16 | @Min(1) int postsPerPage, 17 | List importFilePaths, 18 | int newPostsAgeInDays, 19 | String newPostsNotificationFrequency) {} 20 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/mappers/UserDTOMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.mappers; 2 | 3 | import com.sivalabs.techbuzz.users.domain.dtos.UserDTO; 4 | import com.sivalabs.techbuzz.users.domain.models.User; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Component 8 | public class UserDTOMapper { 9 | 10 | public UserDTO toDTO(User user) { 11 | if (user == null) { 12 | return null; 13 | } 14 | return new UserDTO( 15 | user.getId(), 16 | user.getName(), 17 | user.getEmail(), 18 | user.getRole(), 19 | user.isVerified(), 20 | user.getVerificationToken(), 21 | user.getPasswordResetToken()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /adr/ui-tech-selection.md: -------------------------------------------------------------------------------- 1 | # Technology selection for UI 2 | 3 | ## Question: Which technology to use for building UI? 4 | 5 | ## Options 6 | 1. Separate API and UI and deploy them separately 7 | 2. Separate API and UI, package as a single artifact and deploy 8 | 3. Use a Server Side Templating library like Thymeleaf 9 | 10 | ## Decision 11 | Use Thymeleaf with HTMX, AlpineJS. 12 | 13 | ## Reasons 14 | * Keep the tech stack as minimal as possible while being able to achieve the application needs 15 | * Simpler deployment process 16 | * Using separate SPA UI needs NodeJS setup and brings all its headaches( and benefits too) 17 | * Easier to implement SEO (if one day this app becomes popular, and we care about SEO) 18 | 19 | ## Consequences 20 | * The web layer and UI are coupled 21 | * Can't simply switch Backend/UI with different tech stacks 22 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/GetCategoriesControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | 7 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class GetCategoriesControllerTest extends AbstractIntegrationTest { 11 | @Test 12 | void shouldGetCategoriesList() throws Exception { 13 | mockMvc.perform(get("/api/categories")) 14 | .andExpect(status().isOk()) 15 | .andExpect(jsonPath("$.size()").isNotEmpty()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/registrationStatus.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Status 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 |
17 |

Registration is successful

18 |
19 |
20 |
A verification email is sent to your registered email.
Please verify your account.
21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/AppConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config; 2 | 3 | import com.sivalabs.techbuzz.ApplicationProperties; 4 | import com.sivalabs.techbuzz.notifications.EmailService; 5 | import com.sivalabs.techbuzz.notifications.JavaEmailService; 6 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.mail.javamail.JavaMailSender; 10 | import org.thymeleaf.TemplateEngine; 11 | 12 | @Configuration 13 | public class AppConfig { 14 | 15 | @Bean 16 | @ConditionalOnMissingBean 17 | public EmailService emailService( 18 | JavaMailSender javaMailSender, TemplateEngine templateEngine, ApplicationProperties properties) { 19 | return new JavaEmailService(javaMailSender, templateEngine, properties); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/repositories/PostRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.repositories; 2 | 3 | import com.sivalabs.techbuzz.common.model.PagedResult; 4 | import com.sivalabs.techbuzz.posts.domain.models.Post; 5 | import java.time.LocalDateTime; 6 | import java.util.List; 7 | import java.util.Optional; 8 | 9 | public interface PostRepository { 10 | 11 | PagedResult findByCategorySlug(String categorySlug, Integer page); 12 | 13 | List findPosts(List postIds); 14 | 15 | PagedResult findCreatedPostsByUser(Long userId, Integer page); 16 | 17 | PagedResult findVotedPostsByUser(Long userId, Integer page); 18 | 19 | List findPostCreatedFrom(LocalDateTime createdDateFrom); 20 | 21 | Optional findById(Long postId); 22 | 23 | Post save(Post post); 24 | 25 | Post update(Post post); 26 | 27 | void delete(Long postId); 28 | 29 | long count(); 30 | } 31 | -------------------------------------------------------------------------------- /adr/persistence-library-selection.md: -------------------------------------------------------------------------------- 1 | # Tech selection for database persistence 2 | 3 | ## Question: Which database persistence library to use? 4 | 5 | ## Options: 6 | 1. Spring Data JPA with Hibernate 7 | 2. jOOQ 8 | 3. Spring JdbcTemplate 9 | 10 | ## Decision 11 | Use jOOQ 12 | 13 | ## Reasons 14 | * JPA and Spring Data JPA libraries are very powerful but need good expertise to use them properly 15 | * There is a good chance to misuse JPA, especially while doing anything beyond simple CRUD 16 | * jOOQ provides TypeSafe DSL to perform database operations 17 | * jOOQ doesn't do any magic behind the scenes. jOOQ will exactly execute the query that you tell it to execute. 18 | * With MULTISET feature, loading *-to-Many associations are also easy 19 | 20 | ## Consequences 21 | * With jOOQ it may take a little bit more time to write code compared to JPA, but you won't lose hair debugging JPA issues later :-) 22 | * There could possibly be more effort to switch to a different database compared to JPA 23 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/HomeControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 7 | 8 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 9 | import org.junit.jupiter.api.Test; 10 | 11 | class HomeControllerTest extends AbstractIntegrationTest { 12 | @Test 13 | void shouldHomePageWithCategoriesList() throws Exception { 14 | mockMvc.perform(get("/")) 15 | .andExpect(status().isOk()) 16 | .andExpect(view().name("posts/home")) 17 | .andExpect(model().attributeExists("categories")); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/security/SecurityUser.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.security; 2 | 3 | import com.sivalabs.techbuzz.users.domain.models.User; 4 | import java.util.Set; 5 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 6 | 7 | public class SecurityUser extends org.springframework.security.core.userdetails.User { 8 | private final String name; 9 | private final Long id; 10 | 11 | public SecurityUser(User user) { 12 | super( 13 | user.getEmail(), 14 | user.getPassword(), 15 | user.isVerified(), 16 | true, 17 | true, 18 | true, 19 | Set.of(new SimpleGrantedAuthority(user.getRole().name()))); 20 | 21 | this.name = user.getName(); 22 | this.id = user.getId(); 23 | } 24 | 25 | public String getName() { 26 | return name; 27 | } 28 | 29 | public Long getId() { 30 | return id; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/repositories/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.repositories; 2 | 3 | import com.sivalabs.techbuzz.users.domain.models.User; 4 | import com.sivalabs.techbuzz.users.domain.models.UserProfile; 5 | import java.util.List; 6 | import java.util.Optional; 7 | 8 | public interface UserRepository { 9 | 10 | Optional findByEmail(String email); 11 | 12 | Optional findProfileById(Long id); 13 | 14 | boolean existsByEmail(String email); 15 | 16 | List findVerifiedUsersMailIds(); 17 | 18 | Optional findByEmailAndVerificationToken(String email, String token); 19 | 20 | User save(User user); 21 | 22 | void updateVerificationStatus(User user); 23 | 24 | void updatePasswordResetToken(User user); 25 | 26 | Optional findByEmailAndPasswordResetToken(String email, String token); 27 | 28 | int updatePassword(String email, String verificationCode, String encodedPassword); 29 | } 30 | -------------------------------------------------------------------------------- /e2e-tests/src/test/java/com/sivalabs/techbuzz/config/ConfigLoader.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import java.io.InputStream; 5 | 6 | public class ConfigLoader { 7 | public static Configuration loadConfiguration() { 8 | ObjectMapper objectMapper = new ObjectMapper(); 9 | String configFile = "config.json"; 10 | try { 11 | String configFileOverride = System.getenv("CONFIG_FILE"); 12 | System.out.println("configFileOverride:" + configFileOverride); 13 | if (configFileOverride != null && !configFileOverride.trim().isEmpty()) { 14 | configFile = configFileOverride; 15 | } 16 | InputStream inputStream = ConfigLoader.class.getClassLoader().getResourceAsStream(configFile); 17 | return objectMapper.readValue(inputStream, Configuration.class); 18 | } catch (Exception e) { 19 | throw new RuntimeException(e); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /techbuzz/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /e2e-tests/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /gatling-tests/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/HomeController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.config.logging.Loggable; 4 | import com.sivalabs.techbuzz.posts.domain.services.CategoryService; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.stereotype.Controller; 8 | import org.springframework.ui.Model; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | 11 | @Controller 12 | @Loggable 13 | class HomeController { 14 | private static final Logger log = LoggerFactory.getLogger(HomeController.class); 15 | private final CategoryService categoryService; 16 | 17 | HomeController(CategoryService categoryService) { 18 | this.categoryService = categoryService; 19 | } 20 | 21 | @GetMapping("/") 22 | public String home(Model model) { 23 | log.info("Handle home request"); 24 | model.addAttribute("categories", categoryService.getAllCategories()); 25 | return "posts/home"; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /techbuzz/src/test/resources/posts_created_before_a_month.sql: -------------------------------------------------------------------------------- 1 | delete from votes; 2 | delete from posts; 3 | INSERT INTO posts (title,url,"content",created_by,cat_id,created_at,updated_at) VALUES 4 | ('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help','Here I would like share my thoughts on what are the better ways to ask for help? and what are some patterns that you should avoid while asking for help.',1,9,current_date-8,NULL), 5 | ('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help','Here I would like share my thoughts on what are the better ways to ask for help? and what are some patterns that you should avoid while asking for help.',1,9,current_date-9,NULL), 6 | ('Getting Started with Kubernetes','https://sivalabs.in/getting-started-with-kubernetes','In this article we will learn Creating a docker image from a SpringBoot application, Local kubernetes setup using Minikube, Run the SpringBoot app in a Pod, Scaling the application using Deployment, Exposing the Deployment as a Service',1,6,current_date-8,NULL); 7 | 8 | -------------------------------------------------------------------------------- /techbuzz/src/test/resources/posts_with_mixed_date_range.sql: -------------------------------------------------------------------------------- 1 | delete from votes; 2 | delete from posts; 3 | INSERT INTO posts (title,url,"content",created_by,cat_id,created_at,updated_at) VALUES 4 | ('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help','Here I would like share my thoughts on what are the better ways to ask for help? and what are some patterns that you should avoid while asking for help.',1,9,current_date,NULL), 5 | ('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help','Here I would like share my thoughts on what are the better ways to ask for help? and what are some patterns that you should avoid while asking for help.',1,9,current_date-7,NULL), 6 | ('Getting Started with Kubernetes','https://sivalabs.in/getting-started-with-kubernetes','In this article we will learn Creating a docker image from a SpringBoot application, Local kubernetes setup using Minikube, Run the SpringBoot app in a Pod, Scaling the application using Deployment, Exposing the Deployment as a Service',1,6,current_date-8,NULL); 7 | 8 | -------------------------------------------------------------------------------- /techbuzz/src/main/jooq/com/sivalabs/techbuzz/jooq/Tables.java: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is generated by jOOQ. 3 | */ 4 | package com.sivalabs.techbuzz.jooq; 5 | 6 | 7 | import com.sivalabs.techbuzz.jooq.tables.Categories; 8 | import com.sivalabs.techbuzz.jooq.tables.Posts; 9 | import com.sivalabs.techbuzz.jooq.tables.Users; 10 | import com.sivalabs.techbuzz.jooq.tables.Votes; 11 | 12 | 13 | /** 14 | * Convenience access to all tables in public. 15 | */ 16 | @SuppressWarnings({ "all", "unchecked", "rawtypes" }) 17 | public class Tables { 18 | 19 | /** 20 | * The table public.categories. 21 | */ 22 | public static final Categories CATEGORIES = Categories.CATEGORIES; 23 | 24 | /** 25 | * The table public.posts. 26 | */ 27 | public static final Posts POSTS = Posts.POSTS; 28 | 29 | /** 30 | * The table public.users. 31 | */ 32 | public static final Users USERS = Users.USERS; 33 | 34 | /** 35 | * The table public.votes. 36 | */ 37 | public static final Votes VOTES = Votes.VOTES; 38 | } 39 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/users/web/controllers/ProfileControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 5 | 6 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 7 | import org.junit.jupiter.api.Test; 8 | 9 | public class ProfileControllerTest extends AbstractIntegrationTest { 10 | @Test 11 | void shouldFetchUserProfileDetails() throws Exception { 12 | mockMvc.perform(get("/users/{userId}", 1L)) 13 | .andExpect(status().isOk()) 14 | .andExpect(view().name("users/profile")) 15 | .andExpect(model().attributeExists("userProfile")) 16 | .andExpect(model().attributeExists("userSpecificPostsUrl")); 17 | } 18 | 19 | @Test 20 | void shouldReturn404PageForInvalidUser() throws Exception { 21 | mockMvc.perform(get("/users/{userId}", 10L)).andExpect(status().isNotFound()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /gatling-tests/src/test/java/utils/SimulationHelper.java: -------------------------------------------------------------------------------- 1 | package utils; 2 | 3 | import com.typesafe.config.Config; 4 | import com.typesafe.config.ConfigFactory; 5 | import io.gatling.javaapi.http.HttpDsl; 6 | import io.gatling.javaapi.http.HttpProtocolBuilder; 7 | 8 | public class SimulationHelper { 9 | static final Config config = loadConfig(); 10 | 11 | private static Config loadConfig() { 12 | return ConfigFactory.load(); 13 | } 14 | 15 | public static Config getConfig() { 16 | return config; 17 | } 18 | 19 | public static HttpProtocolBuilder getHttpProtocolBuilder() { 20 | return HttpDsl.http 21 | .baseUrl(config.getString("app.baseUrl")) 22 | .acceptHeader("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") 23 | .acceptLanguageHeader("en-US,en;q=0.5") 24 | .acceptEncodingHeader("gzip, deflate") 25 | .userAgentHeader("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0") 26 | // .disableFollowRedirect() 27 | ; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/db/migration/V2__insert_user_categories.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO users (email, password, name, role, verified, created_at) 2 | VALUES ('sivalabs.in@gmail.com', '$2a$10$0ayT2AX2neetmzmQFzuIA.sQOEyLzNN9jvEHv6/lqBexsqndZGyDK', 'SivaLabs', 'ROLE_ADMIN', true, CURRENT_TIMESTAMP) 3 | ; 4 | 5 | INSERT INTO categories(id, name, slug, description, image, display_order) 6 | VALUES (1, 'Java', 'java', 'Java and JVM related technologies', 'java.png', 1), 7 | (2, 'WebDev', 'webdev', 'Web development using HTML, JS, CSS', 'webdev.png', 2), 8 | (3, 'Go', 'go', 'Go programming language', 'go.png', 3), 9 | (4, 'Python', 'python', 'Python programming language', 'python.png', 4), 10 | (5, 'NodeJS', 'nodejs', 'NodeJS application development', 'nodejs.png', 5), 11 | (6, 'DevOps', 'devops', 'DevOps culture, practices, tools and techniques', 'devops.png', 6), 12 | (7, 'Testing', 'testing', 'Software Testing techniques and technologies', 'testing.png', 7), 13 | (8, 'Career', 'career', 'Career guidance and support', 'career.png', 8), 14 | (9, 'General', 'general', 'Anything related to software development', 'general.png', 9) 15 | ; 16 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/dtos/PostViewDTO.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.dtos; 2 | 3 | import com.sivalabs.techbuzz.posts.domain.models.Category; 4 | import com.sivalabs.techbuzz.posts.domain.models.Vote; 5 | import com.sivalabs.techbuzz.users.domain.dtos.UserDTO; 6 | import java.time.LocalDateTime; 7 | import java.util.Set; 8 | 9 | public record PostViewDTO( 10 | Long id, 11 | String title, 12 | String url, 13 | String content, 14 | Category category, 15 | Set votes, 16 | UserDTO createdBy, 17 | LocalDateTime createdAt, 18 | LocalDateTime updatedAt, 19 | boolean editable, 20 | boolean upVoted, 21 | boolean downVoted) { 22 | 23 | public long getUpVoteCount() { 24 | if (votes == null) { 25 | return 0; 26 | } 27 | return votes.stream().filter(v -> v.getValue() == 1).count(); 28 | } 29 | 30 | public long getDownVoteCount() { 31 | if (votes == null) { 32 | return 0; 33 | } 34 | return votes.stream().filter(v -> v.getValue() == -1).count(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/fragments/user-posts.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
8 |
9 |
10 |
11 |
12 | 13 |
14 | 18 |
19 |
20 |
21 | 22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/common/AbstractIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.common; 2 | 3 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 4 | 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import com.sivalabs.techbuzz.TestcontainersConfig; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.context.annotation.Import; 11 | import org.springframework.test.context.ActiveProfiles; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | @ActiveProfiles({"test"}) 15 | @SpringBootTest(webEnvironment = RANDOM_PORT) 16 | @AutoConfigureMockMvc 17 | @Import(TestcontainersConfig.class) 18 | public abstract class AbstractIntegrationTest { 19 | public static final String ADMIN_EMAIL = "admin@gmail.com"; 20 | 21 | static { 22 | TestcontainersConfig.init(); 23 | } 24 | 25 | @Autowired 26 | protected MockMvc mockMvc; 27 | 28 | @Autowired 29 | protected ObjectMapper objectMapper; 30 | } 31 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/users/web/controllers/LoginControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 7 | 8 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 9 | import org.junit.jupiter.api.Test; 10 | 11 | class LoginControllerTest extends AbstractIntegrationTest { 12 | 13 | @Test 14 | void shouldShowLoginFormPage() throws Exception { 15 | mockMvc.perform(get("/login")).andExpect(status().isOk()).andExpect(view().name("users/login")); 16 | } 17 | 18 | @Test 19 | void shouldShowLoginFormPageWithError() throws Exception { 20 | mockMvc.perform(get("/login?error")) 21 | .andExpect(status().isOk()) 22 | .andExpect(view().name("users/login")) 23 | .andExpect(model().attributeExists("errorMessage")); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/fragments/pagination.html: -------------------------------------------------------------------------------- 1 |
2 | 18 |
19 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config; 2 | 3 | import com.sivalabs.techbuzz.config.argresolvers.CurrentUserArgumentResolver; 4 | import java.util.List; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.web.filter.HiddenHttpMethodFilter; 8 | import org.springframework.web.method.support.HandlerMethodArgumentResolver; 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 10 | 11 | @Configuration 12 | public class WebMvcConfig implements WebMvcConfigurer { 13 | 14 | private final CurrentUserArgumentResolver currentUserArgumentResolver; 15 | 16 | public WebMvcConfig(CurrentUserArgumentResolver currentUserArgumentResolver) { 17 | this.currentUserArgumentResolver = currentUserArgumentResolver; 18 | } 19 | 20 | @Override 21 | public void addArgumentResolvers(List resolvers) { 22 | resolvers.add(currentUserArgumentResolver); 23 | } 24 | 25 | @Bean 26 | public HiddenHttpMethodFilter hiddenHttpMethodFilter() { 27 | return new HiddenHttpMethodFilter(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /techbuzz/src/main/jooq/com/sivalabs/techbuzz/jooq/Sequences.java: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is generated by jOOQ. 3 | */ 4 | package com.sivalabs.techbuzz.jooq; 5 | 6 | 7 | import org.jooq.Sequence; 8 | import org.jooq.impl.Internal; 9 | import org.jooq.impl.SQLDataType; 10 | 11 | 12 | /** 13 | * Convenience access to all sequences in public. 14 | */ 15 | @SuppressWarnings({ "all", "unchecked", "rawtypes" }) 16 | public class Sequences { 17 | 18 | /** 19 | * The sequence public.post_id_seq 20 | */ 21 | public static final Sequence POST_ID_SEQ = Internal.createSequence("post_id_seq", Public.PUBLIC, SQLDataType.BIGINT.nullable(false), null, 5, null, null, false, null); 22 | 23 | /** 24 | * The sequence public.user_id_seq 25 | */ 26 | public static final Sequence USER_ID_SEQ = Internal.createSequence("user_id_seq", Public.PUBLIC, SQLDataType.BIGINT.nullable(false), null, 5, null, null, false, null); 27 | 28 | /** 29 | * The sequence public.vote_id_seq 30 | */ 31 | public static final Sequence VOTE_ID_SEQ = Internal.createSequence("vote_id_seq", Public.PUBLIC, SQLDataType.BIGINT.nullable(false), null, 5, null, null, false, null); 32 | } 33 | -------------------------------------------------------------------------------- /gatling-tests/src/test/resources/data/feeders/posts.csv: -------------------------------------------------------------------------------- 1 | url,title,category 2 | https://dzone.com/articles/amazon-lightsail-virtual-cloud-server,"Amazon Lightsail: Virtual Cloud Server",1 3 | https://dzone.com/articles/kubernetes-data-simplicity-getting-started-with-k8,"Kubernetes Data Simplicity: Getting Started With K8ssandra",1 4 | https://dzone.com/articles/developing-a-cloud-adoption-strategy-1,"Developing a Cloud Adoption Strategy",2 5 | https://dzone.com/articles/everything-you-need,"Everything You Need to Know About Cloud Automation in 2022",2 6 | https://dzone.com/articles/what-is-cloud-storage-definition-types-pros-and-co,"What Is Cloud Storage: Definition, Types, Pros, and Cons",2 7 | https://dzone.com/articles/the-need-for-a-kubernetes-alternative,"The Need for a Kubernetes Alternative",1 8 | https://dzone.com/articles/debugging-a-wordle-bug,"Debugging a Wordle Bug",3 9 | https://dzone.com/articles/upgrading-kubernetes-cluster-with-cluster-api-on-oracle-cloud,"Upgrading Kubernetes Clusters With Cluster API on Oracle Cloud",3 10 | https://dzone.com/articles/common-mistakes-to-avoid-when-migrating,"Common Mistakes to Avoid When Migrating",3 11 | https://dzone.com/articles/cloud-tagging-strategic-practices-1,"Cloud Tagging Strategic Practices",4 12 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/GetCategoriesController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.config.logging.Loggable; 4 | import com.sivalabs.techbuzz.posts.domain.models.Category; 5 | import com.sivalabs.techbuzz.posts.domain.repositories.CategoryRepository; 6 | import java.util.List; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | @RestController 14 | @RequestMapping("/api/categories") 15 | @Loggable 16 | public class GetCategoriesController { 17 | private static final Logger log = LoggerFactory.getLogger(GetCategoriesController.class); 18 | private final CategoryRepository categoryRepository; 19 | 20 | public GetCategoriesController(CategoryRepository categoryRepository) { 21 | this.categoryRepository = categoryRepository; 22 | } 23 | 24 | @GetMapping 25 | public List allCategories() { 26 | log.info("Fetching all categories"); 27 | return categoryRepository.findAll(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/models/UserProfile.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | public class UserProfile { 6 | 7 | private Long id; 8 | private String name; 9 | private LocalDateTime activeFrom; 10 | 11 | private String email; 12 | 13 | public UserProfile(Long id, String name, String email, LocalDateTime activeFrom) { 14 | this.id = id; 15 | this.name = name; 16 | this.activeFrom = activeFrom; 17 | this.email = email; 18 | } 19 | 20 | public Long getId() { 21 | return id; 22 | } 23 | 24 | public void setId(Long id) { 25 | this.id = id; 26 | } 27 | 28 | public String getName() { 29 | return name; 30 | } 31 | 32 | public void setName(String name) { 33 | this.name = name; 34 | } 35 | 36 | public LocalDateTime getActiveFrom() { 37 | return activeFrom; 38 | } 39 | 40 | public void setActiveFrom(LocalDateTime activeFrom) { 41 | this.activeFrom = activeFrom; 42 | } 43 | 44 | public String getEmail() { 45 | return email; 46 | } 47 | 48 | public void setEmail(String email) { 49 | this.email = email; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/fragments/minimal-post.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
8 |
9 |
10 |

11 | 12 |

13 |

14 |

15 | 16 | Posted By: Name 19 | 20 | Category: Category 22 | 23 |
24 |
25 | Post content 26 |
27 | 28 | 29 |
30 |
31 |
32 | 33 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/services/CategoryService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.services; 2 | 3 | import com.sivalabs.techbuzz.posts.domain.models.Category; 4 | import com.sivalabs.techbuzz.posts.domain.repositories.CategoryRepository; 5 | import java.util.List; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.cache.annotation.Cacheable; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | @Service 13 | @Transactional 14 | public class CategoryService { 15 | private static final Logger log = LoggerFactory.getLogger(CategoryService.class); 16 | private final CategoryRepository categoryRepository; 17 | 18 | public CategoryService(CategoryRepository categoryRepository) { 19 | this.categoryRepository = categoryRepository; 20 | } 21 | 22 | @Cacheable("categories") 23 | public List getAllCategories() { 24 | log.debug("Fetching all categories"); 25 | return categoryRepository.findAll(); 26 | } 27 | 28 | @Cacheable("category") 29 | public Category getCategory(String categorySlug) { 30 | log.debug("Fetching category by slug: {}", categorySlug); 31 | return categoryRepository.findBySlug(categorySlug).orElseThrow(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/ViewCategoryControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 7 | 8 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 9 | import org.junit.jupiter.params.ParameterizedTest; 10 | import org.junit.jupiter.params.provider.CsvSource; 11 | 12 | class ViewCategoryControllerTests extends AbstractIntegrationTest { 13 | 14 | @ParameterizedTest 15 | @CsvSource({"go", "java"}) 16 | void shouldFetchPostsByCategory(String category) throws Exception { 17 | mockMvc.perform(get("/c/{category}", category)) 18 | .andExpect(status().isOk()) 19 | .andExpect(view().name("posts/category")) 20 | .andExpect(model().attributeExists("paginationPrefix")) 21 | .andExpect(model().attributeExists("category")) 22 | .andExpect(model().attributeExists("postsData")) 23 | .andExpect(model().attributeExists("categories")); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /deployment/docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | techbuzz: 4 | image: sivaprasadreddy/techbuzz 5 | container_name: techbuzz 6 | ports: 7 | - "8080:8080" 8 | restart: unless-stopped 9 | environment: 10 | SPRING_PROFILES_ACTIVE: docker 11 | DB_HOST: "${DB_HOST}" 12 | DB_PORT: "${DB_PORT}" 13 | DB_DATABASE: "${DB_DATABASE}" 14 | DB_USERNAME: "${DB_USERNAME}" 15 | DB_PASSWORD: "${DB_PASSWORD}" 16 | EMAIL_PROVIDER: "${EMAIL_PROVIDER}" 17 | SENDGRID_API_KEY: "${SENDGRID_API_KEY}" 18 | MAIL_HOST: "${MAIL_HOST}" 19 | MAIL_PORT: "${MAIL_PORT}" 20 | deploy: 21 | resources: 22 | limits: 23 | memory: 1024m 24 | profiles: 25 | - app 26 | 27 | techbuzz-db: 28 | image: postgres:17-alpine 29 | container_name: techbuzz-db 30 | environment: 31 | POSTGRES_USER: "${DB_USERNAME}" 32 | POSTGRES_PASSWORD: "${DB_PASSWORD}" 33 | POSTGRES_DB: "${DB_DATABASE}" 34 | ports: 35 | - "15432:5432" 36 | healthcheck: 37 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 38 | interval: 10s 39 | timeout: 5s 40 | retries: 5 41 | 42 | mailhog: 43 | image: mailhog/mailhog 44 | platform: linux/x86_64 45 | container_name: mailhog 46 | ports: 47 | - "1025:1025" 48 | - "8025:8025" 49 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/fragments/pagination-in-tabs.html: -------------------------------------------------------------------------------- 1 |
2 | 18 |
19 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/emailVerification.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Status 9 | 10 | 11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 |

Your email is verified successfully

19 |
20 |
21 |

Your email verification is failed

22 |
23 |
24 |
25 |
26 |
You can Login or visit Home page
27 |
28 |
29 |
Please check your email and verify your account
30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/posts/home.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Home 9 | 10 | 11 | 12 |
13 | 14 |
15 |

Welcome to TechBuzz

16 | 32 |
33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/security/SecurityUserDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.security; 2 | 3 | import com.sivalabs.techbuzz.users.domain.repositories.UserRepository; 4 | import java.util.Optional; 5 | import org.springframework.security.authentication.DisabledException; 6 | import org.springframework.security.core.userdetails.UserDetails; 7 | import org.springframework.security.core.userdetails.UserDetailsService; 8 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 9 | import org.springframework.stereotype.Service; 10 | 11 | @Service("userDetailsService") 12 | public class SecurityUserDetailsService implements UserDetailsService { 13 | private final UserRepository userRepository; 14 | 15 | public SecurityUserDetailsService(UserRepository userRepository) { 16 | this.userRepository = userRepository; 17 | } 18 | 19 | @Override 20 | public UserDetails loadUserByUsername(String username) { 21 | Optional securityUser = 22 | userRepository.findByEmail(username).map(SecurityUser::new); 23 | if (securityUser.isEmpty()) { 24 | throw new UsernameNotFoundException("No user found with username " + username); 25 | } 26 | if (!securityUser.orElseThrow().isEnabled()) { 27 | throw new DisabledException("Account verification is pending"); 28 | } 29 | return securityUser.orElseThrow(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | declare dc_file=deployment/docker-compose/docker-compose.yml 4 | 5 | function build_apps_buildpacks() { 6 | ./mvnw -pl techbuzz clean spring-boot:build-image -Dspring-boot.build-image.imageName=sivaprasadreddy/techbuzz 7 | } 8 | 9 | function build_apps() { 10 | ./mvnw -pl techbuzz spotless:apply 11 | build_apps_buildpacks 12 | } 13 | 14 | function start_infra() { 15 | echo "Starting dependent docker containers...." 16 | docker-compose -f "${dc_file}" up --build --force-recreate -d 17 | docker-compose -f "${dc_file}" logs -f 18 | } 19 | 20 | function stop_infra() { 21 | echo "Stopping dependent docker containers...." 22 | docker-compose -f "${dc_file}" stop 23 | docker-compose -f "${dc_file}" rm -f 24 | } 25 | 26 | function restart_infra() { 27 | stop_infra 28 | sleep 5 29 | start_infra 30 | } 31 | 32 | function start() { 33 | echo "Starting app...." 34 | build_apps 35 | docker-compose --profile app -f "${dc_file}" up --build --force-recreate -d 36 | docker-compose --profile app -f "${dc_file}" logs -f 37 | } 38 | 39 | function stop() { 40 | echo 'Stopping app....' 41 | docker-compose --profile app -f "${dc_file}" stop 42 | docker-compose --profile app -f "${dc_file}" rm -f 43 | } 44 | 45 | function restart() { 46 | stop 47 | sleep 3 48 | start 49 | } 50 | 51 | action="start" 52 | 53 | if [[ "$#" != "0" ]] 54 | then 55 | action=$* 56 | fi 57 | 58 | eval "${action}" 59 | -------------------------------------------------------------------------------- /e2e-tests/src/test/java/com/sivalabs/techbuzz/PostsBrowsingTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.params.ParameterizedTest; 7 | import org.junit.jupiter.params.provider.CsvSource; 8 | 9 | class PostsBrowsingTests extends BaseTest { 10 | 11 | @Test 12 | void shouldViewCategoriesOnHomePage() { 13 | page.navigate(rootUrl); 14 | int postsCount = page.locator(".category-card").count(); 15 | assertThat(postsCount).isGreaterThan(0); 16 | } 17 | 18 | @ParameterizedTest 19 | @CsvSource({"java", "webdev", "go", "python", "nodejs", "devops", "testing", "career", "general"}) 20 | void shouldViewPostsOnCategoryPage(String category) { 21 | page.navigate(rootUrl + "/c/" + category); 22 | int postsCount = page.locator(".post").count(); 23 | assertThat(postsCount).isGreaterThan(0); 24 | } 25 | 26 | @ParameterizedTest 27 | @CsvSource({"java"}) 28 | void shouldNavigateBetweenPostPagesUsingPaginator(String category) { 29 | page.navigate(rootUrl + "/c/" + category); 30 | int postsCount = page.locator(".post").count(); 31 | assertThat(postsCount).isGreaterThan(0); 32 | page.locator("text='Next'").first().click(); 33 | page.locator("text='Previous'").first().click(); 34 | page.locator("text='Last'").first().click(); 35 | page.locator("text='First'").first().click(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | vars: 4 | GOOS: "{{default OS .GOOS}}" 5 | MVNW: '{{if eq .GOOS "windows"}}mvnw.cmd{{else}}./mvnw{{end}}' 6 | IMAGE_NAME: 'sivaprasadreddy/techbuzz' 7 | DC_DIR: "deployment/docker-compose" 8 | DC_FILE: "{{.DC_DIR}}/docker-compose.yml" 9 | 10 | tasks: 11 | default: 12 | cmds: 13 | - task: test 14 | test: 15 | deps: [format] 16 | cmds: 17 | - "{{.MVNW}} clean verify" 18 | 19 | format: 20 | cmds: 21 | - "{{.MVNW}} spotless:apply" 22 | 23 | build_image: 24 | cmds: 25 | - "{{.MVNW}} -pl techbuzz clean compile spring-boot:build-image -DskipTests -DdockerImageName={{.IMAGE_NAME}}" 26 | 27 | start_infra: 28 | cmds: 29 | - docker compose -f "{{.DC_FILE}}" up -d 30 | 31 | stop_infra: 32 | cmds: 33 | - docker compose -f "{{.DC_FILE}}" stop 34 | - docker compose -f "{{.DC_FILE}}" rm -f 35 | 36 | restart_infra: 37 | cmds: 38 | - task: stop_infra 39 | - task: sleep 40 | - task: start_infra 41 | 42 | start: 43 | deps: [build_image] 44 | cmds: 45 | - docker compose --profile app -f "{{.DC_FILE}}" up --force-recreate -d 46 | 47 | stop: 48 | cmds: 49 | - docker compose --profile app -f "{{.DC_FILE}}" stop 50 | - docker compose --profile app -f "{{.DC_FILE}}" rm -f 51 | 52 | restart: 53 | cmds: 54 | - task: stop 55 | - task: sleep 56 | - task: start 57 | 58 | sleep: 59 | vars: 60 | DURATION: '{{default 5 .DURATION}}' 61 | cmds: 62 | - sleep {{.DURATION}} -------------------------------------------------------------------------------- /gatling-tests/src/test/java/techbuzz/PostsBrowsingSimulation.java: -------------------------------------------------------------------------------- 1 | package techbuzz; 2 | 3 | import static io.gatling.javaapi.core.CoreDsl.*; 4 | import static io.gatling.javaapi.http.HttpDsl.*; 5 | import static utils.SimulationHelper.getConfig; 6 | 7 | import io.gatling.javaapi.core.*; 8 | import io.gatling.javaapi.http.*; 9 | import utils.SimulationHelper; 10 | 11 | public class PostsBrowsingSimulation extends Simulation { 12 | 13 | HttpProtocolBuilder httpProtocol = SimulationHelper.getHttpProtocolBuilder(); 14 | 15 | FeederBuilder categoryFeeder = csv("data/feeders/categories.csv").random(); 16 | 17 | ChainBuilder byCategory = feed(categoryFeeder) 18 | .repeat(5, "n") 19 | .on(exec(session -> session.set("pageNo", (int) session.get("n") + 1)) 20 | .exec(http("Posts By Category").get("/c/#{category}?page=#{pageNo}")) 21 | .pause(3)); 22 | 23 | ChainBuilder browsePosts = exec(byCategory); 24 | 25 | // ScenarioBuilder scnBrowsePosts = 26 | // scenario("Browse Posts").during(Duration.ofMinutes(2), "Counter").on(browsePosts); 27 | 28 | ScenarioBuilder scnBrowsePosts = scenario("Browse Posts").exec(browsePosts); 29 | 30 | { 31 | setUp(scnBrowsePosts.injectOpen(rampUsers(getConfig().getInt("users")).during(10))) 32 | .protocols(httpProtocol) 33 | .assertions( 34 | global().responseTime().max().lt(800), 35 | global().successfulRequests().percent().is(100.0)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | com.sivalabs 9 | techbuzz-parent 10 | 0.0.1 11 | pom 12 | techbuzz-parent 13 | TechBuzz 14 | 15 | 16 | UTF-8 17 | 2.43.0 18 | 19 | 20 | 21 | techbuzz 22 | e2e-tests 23 | gatling-tests 24 | 25 | 26 | 27 | 28 | 29 | com.diffplug.spotless 30 | spotless-maven-plugin 31 | ${spotless.version} 32 | 33 | 34 | 35 | 36 | 37 | 2.30.0 38 | 39 | 40 | 41 | 42 | 43 | 44 | compile 45 | 46 | check 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/web/controllers/LoginController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.config.logging.Loggable; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpSession; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.security.core.AuthenticationException; 9 | import org.springframework.security.web.WebAttributes; 10 | import org.springframework.stereotype.Controller; 11 | import org.springframework.ui.Model; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | 14 | @Controller 15 | @Loggable 16 | class LoginController { 17 | 18 | private static final Logger log = LoggerFactory.getLogger(LoginController.class); 19 | 20 | @GetMapping("/login") 21 | public String login(HttpServletRequest request, Model model) { 22 | if (request.getParameterMap().containsKey("error")) { 23 | HttpSession session = request.getSession(false); 24 | String errorMessage = "Failed to login, Try again"; 25 | if (session != null) { 26 | AuthenticationException ex = 27 | (AuthenticationException) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); 28 | if (ex != null) { 29 | errorMessage = ex.getMessage(); 30 | } 31 | } 32 | model.addAttribute("errorMessage", errorMessage); 33 | log.error(errorMessage); 34 | } 35 | return "users/login"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | env: 4 | APP_NAME: techbuzz 5 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 6 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 7 | DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/techbuzz 8 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 9 | 10 | on: 11 | push: 12 | branches: 13 | - 'main' 14 | paths-ignore: 15 | - 'README.md' 16 | - 'adr/**' 17 | - 'deployment/**' 18 | - '.gitpod.*' 19 | - '.devcontainer/**' 20 | - 'run.sh' 21 | 22 | pull_request: 23 | branches: [ "main" ] 24 | 25 | jobs: 26 | build: 27 | name: Maven Build 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Setup Java 34 | uses: actions/setup-java@v4 35 | with: 36 | java-version: '21' 37 | distribution: 'temurin' 38 | cache: 'maven' 39 | 40 | - name: Build with Maven 41 | run: ./mvnw -ntp verify 42 | 43 | - if: ${{ github.ref == 'refs/heads/main' }} 44 | name: Sonar Scan 45 | run: ./mvnw initialize sonar:sonar -Dsonar.login=${{ env.SONAR_TOKEN }} 46 | working-directory: techbuzz 47 | 48 | - if: ${{ github.ref == 'refs/heads/main' }} 49 | name: Build and Publish Docker Image 50 | run: | 51 | ./mvnw -pl techbuzz spring-boot:build-image -DskipTests -Dspring-boot.build-image.imageName=${{ env.DOCKER_IMAGE_NAME }} 52 | docker login -u ${{ env.DOCKER_USERNAME }} -p ${{ env.DOCKER_PASSWORD }} 53 | docker push ${{ env.DOCKER_IMAGE_NAME }} 54 | -------------------------------------------------------------------------------- /techbuzz/src/main/jooq/com/sivalabs/techbuzz/jooq/DefaultCatalog.java: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is generated by jOOQ. 3 | */ 4 | package com.sivalabs.techbuzz.jooq; 5 | 6 | 7 | import java.util.Arrays; 8 | import java.util.List; 9 | 10 | import org.jooq.Constants; 11 | import org.jooq.Schema; 12 | import org.jooq.impl.CatalogImpl; 13 | 14 | 15 | /** 16 | * This class is generated by jOOQ. 17 | */ 18 | @SuppressWarnings({ "all", "unchecked", "rawtypes" }) 19 | public class DefaultCatalog extends CatalogImpl { 20 | 21 | private static final long serialVersionUID = 1L; 22 | 23 | /** 24 | * The reference instance of DEFAULT_CATALOG 25 | */ 26 | public static final DefaultCatalog DEFAULT_CATALOG = new DefaultCatalog(); 27 | 28 | /** 29 | * The schema public. 30 | */ 31 | public final Public PUBLIC = Public.PUBLIC; 32 | 33 | /** 34 | * No further instances allowed 35 | */ 36 | private DefaultCatalog() { 37 | super(""); 38 | } 39 | 40 | @Override 41 | public final List getSchemas() { 42 | return Arrays.asList( 43 | Public.PUBLIC 44 | ); 45 | } 46 | 47 | /** 48 | * A reference to the 3.18 minor release of the code generator. If this 49 | * doesn't compile, it's because the runtime library uses an older minor 50 | * release, namely: 3.18. You can turn off the generation of this reference 51 | * by specifying /configuration/generator/generate/jooqVersionReference 52 | */ 53 | private static final String REQUIRE_RUNTIME_JOOQ_VERSION = Constants.VERSION_3_18; 54 | } 55 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/web/controllers/EmailVerificationController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.exceptions.TechBuzzException; 4 | import com.sivalabs.techbuzz.config.logging.Loggable; 5 | import com.sivalabs.techbuzz.users.domain.services.UserService; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.stereotype.Controller; 9 | import org.springframework.ui.Model; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | 13 | @Controller 14 | @Loggable 15 | class EmailVerificationController { 16 | 17 | private static final Logger logger = LoggerFactory.getLogger(EmailVerificationController.class); 18 | private final UserService userService; 19 | 20 | public EmailVerificationController(UserService userService) { 21 | this.userService = userService; 22 | } 23 | 24 | @GetMapping("/verify-email") 25 | public String verifyEmail(Model model, @RequestParam("email") String email, @RequestParam("token") String token) { 26 | logger.info("Verifying email {}", email); 27 | try { 28 | userService.verifyEmail(email, token); 29 | model.addAttribute("success", true); 30 | logger.info("Email verification successful for email: {}", email); 31 | } catch (TechBuzzException e) { 32 | model.addAttribute("success", false); 33 | logger.error("Email verification failed for email: {}. Error: {}", email, e.getMessage()); 34 | } 35 | return "users/emailVerification"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/web/controllers/ProfileController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.exceptions.ResourceNotFoundException; 4 | import com.sivalabs.techbuzz.config.logging.Loggable; 5 | import com.sivalabs.techbuzz.users.domain.models.UserProfile; 6 | import com.sivalabs.techbuzz.users.domain.services.UserService; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.stereotype.Controller; 10 | import org.springframework.ui.Model; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | 14 | @Controller 15 | @Loggable 16 | public class ProfileController { 17 | private static final Logger log = LoggerFactory.getLogger(ProfileController.class); 18 | private final UserService userService; 19 | 20 | public ProfileController(UserService userService) { 21 | this.userService = userService; 22 | } 23 | 24 | @GetMapping("/users/{userId}") 25 | public String getUserProfile(@PathVariable(name = "userId") Long userId, Model model) { 26 | log.info("Fetching user profile for {}", userId); 27 | String userSpecificPostsUrl = "/users/" + userId + "/posts/"; 28 | UserProfile userProfile = userService 29 | .getUserProfile(userId) 30 | .orElseThrow(() -> new ResourceNotFoundException(String.format("User Id %s not found", userId))); 31 | model.addAttribute("userProfile", userProfile); 32 | model.addAttribute("userSpecificPostsUrl", userSpecificPostsUrl); 33 | return "users/profile"; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/java 3 | { 4 | "name": "Java", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/java:21", 7 | 8 | // Configure tool-specific properties. 9 | "customizations": { 10 | // Configure properties specific to VS Code. 11 | "vscode": { 12 | // Add the IDs of extensions you want installed when the container is created. 13 | "extensions": [ 14 | "vscjava.vscode-java-pack" 15 | ] 16 | } 17 | }, 18 | 19 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 20 | //"remoteUser": "vscode", 21 | 22 | "features": { 23 | "ghcr.io/devcontainers/features/git:1": {}, 24 | "ghcr.io/devcontainers/features/github-cli:1": {}, 25 | "ghcr.io/devcontainers/features/java:1": { 26 | "version": "none", 27 | "installMaven": "true", 28 | "installGradle": "false" 29 | }, 30 | "ghcr.io/devcontainers-contrib/features/ant-sdkman:2": {}, 31 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, 32 | "ghcr.io/devcontainers/features/sshd:1": { 33 | "version": "latest" 34 | }, 35 | "ghcr.io/devcontainers/features/node:1": {} 36 | } 37 | 38 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 39 | //"forwardPorts": [8080] 40 | 41 | // Use 'postCreateCommand' to run commands after the container is created. 42 | //"postCreateCommand": "java -version", 43 | //"postStartCommand": ["./mvnw", "compile"] 44 | 45 | } 46 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/security/SecurityService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.security; 2 | 3 | import com.sivalabs.techbuzz.users.domain.models.User; 4 | import com.sivalabs.techbuzz.users.domain.services.UserService; 5 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.security.core.userdetails.UserDetails; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | @Service 13 | @Transactional 14 | public class SecurityService { 15 | private final UserService userService; 16 | 17 | public SecurityService(UserService userService) { 18 | this.userService = userService; 19 | } 20 | 21 | public User loginUser() { 22 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 23 | if (authentication == null || authentication.getPrincipal() == null) { 24 | return null; 25 | } 26 | 27 | Object principal = authentication.getPrincipal(); 28 | if (principal instanceof SecurityUser securityUser) { 29 | String username = securityUser.getUsername(); 30 | return userService.getUserByEmail(username).orElse(null); 31 | } else if (authentication instanceof UsernamePasswordAuthenticationToken) { 32 | UserDetails userDetails = (UserDetails) principal; 33 | return userService.getUserByEmail(userDetails.getUsername()).orElse(null); 34 | } 35 | return null; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=techbuzz 2 | server.port=8080 3 | server.shutdown=graceful 4 | 5 | ################ TechBuzz ##################### 6 | techbuzz.admin-email=${ADMIN_EMAIL:sivalabs.in@gmail.com} 7 | techbuzz.email-provider=${EMAIL_PROVIDER:java} 8 | techbuzz.sendgrid-api-key=${SENDGRID_API_KEY:apiKey} 9 | techbuzz.posts-per-page=10 10 | techbuzz.import-file-paths=/data/posts.json 11 | 12 | ################ New Posts Notification ##################### 13 | techbuzz.new-posts-notification-frequency= 0 30 8 * * SUN 14 | techbuzz.new-posts-age-in-days=7 15 | 16 | ################ Logging ##################### 17 | logging.level.com.sivalabs=DEBUG 18 | #logging.level.org.jooq.tools.LoggerListener=DEBUG 19 | 20 | ################ Actuator ##################### 21 | management.endpoints.web.exposure.include=* 22 | management.health.mail.enabled=false 23 | 24 | ################ Database ##################### 25 | spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_DATABASE:postgres} 26 | spring.datasource.username=${DB_USERNAME:postgres} 27 | spring.datasource.password=${DB_PASSWORD:postgres} 28 | 29 | ############# Mail Properties ########### 30 | # gmail 31 | MAIL_HOST=127.0.0.1 32 | MAIL_PORT=1025 33 | spring.mail.host=${MAIL_HOST:smtp.gmail.com} 34 | spring.mail.port=${MAIL_PORT:587} 35 | spring.mail.username=${MAIL_USERNAME:PLACEHOLDER} 36 | spring.mail.password=${MAIL_PASSWORD:PLACEHOLDER} 37 | spring.mail.properties.mail.smtp.auth=true 38 | spring.mail.properties.mail.smtp.starttls.enable=true 39 | 40 | ########## Cache ############ 41 | spring.cache.cache-names=categories,category,user 42 | spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s 43 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/TestcontainersConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import org.springframework.boot.test.context.TestConfiguration; 4 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 5 | import org.springframework.context.annotation.Bean; 6 | import org.testcontainers.containers.GenericContainer; 7 | import org.testcontainers.containers.PostgreSQLContainer; 8 | 9 | @TestConfiguration(proxyBeanMethods = false) 10 | public class TestcontainersConfig { 11 | 12 | @Bean 13 | @ServiceConnection 14 | PostgreSQLContainer postgreSQLContainer() { 15 | return new PostgreSQLContainer<>("postgres:17-alpine"); 16 | } 17 | 18 | /*@Bean 19 | GenericContainer mailhogContainer(DynamicPropertyRegistry registry) { 20 | var container = new GenericContainer("mailhog/mailhog").withExposedPorts(1025, 8025); 21 | registry.add("spring.mail.host", container::getHost); 22 | registry.add("spring.mail.port", () -> String.valueOf(container.getMappedPort(1025))); 23 | return container; 24 | }*/ 25 | 26 | // JavaMailSender bean is getting initialized even before mailhogContainer() bean is configured. 27 | // So, the dynamic mail server host and port are not properly configured and using the default config. 28 | // Temporary fix: Spin up mail server and set spring mail properties. 29 | public static void init() { 30 | var container = new GenericContainer("mailhog/mailhog").withExposedPorts(1025, 8025); 31 | container.start(); 32 | System.setProperty("spring.mail.host", container.getHost()); 33 | System.setProperty("spring.mail.port", String.valueOf(container.getMappedPort(1025))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/email/new-posts-email.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | TechBuzz - New Posts 10 | 11 | 14 | 15 | 16 |
17 |
18 | 24 |
25 |

NEW POSTS THIS WEEK

26 | 27 |
28 |
29 |
30 |
31 | 32 | 33 |
34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 | 42 |
43 |
44 |
45 | 46 |
47 |

© TechBuzz 2023

48 |
49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/domain/models/PostTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.models; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import com.sivalabs.techbuzz.users.domain.models.RoleEnum; 6 | import com.sivalabs.techbuzz.users.domain.models.User; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.params.ParameterizedTest; 9 | import org.junit.jupiter.params.provider.EnumSource; 10 | 11 | class PostTest { 12 | @Test 13 | void shouldNotBeAbleToEditPostByOtherNormalUsers() { 14 | User user = new User(1L); 15 | user.setRole(RoleEnum.ROLE_USER); 16 | Post post = new Post(); 17 | post.setId(2L); 18 | post.setCreatedBy(user); 19 | User otherUser = new User(9L); 20 | 21 | assertThat(post.canEditByUser(otherUser)).isFalse(); 22 | } 23 | 24 | @Test 25 | void shouldBeAbleToEditPostByCreatedUser() { 26 | User user = new User(1L); 27 | user.setRole(RoleEnum.ROLE_USER); 28 | Post post = new Post(); 29 | post.setId(2L); 30 | post.setCreatedBy(user); 31 | 32 | assertThat(post.canEditByUser(user)).isTrue(); 33 | } 34 | 35 | @ParameterizedTest 36 | @EnumSource( 37 | value = RoleEnum.class, 38 | names = {"ROLE_ADMIN", "ROLE_MODERATOR"}) 39 | void shouldBeAbleToEditPostByOtherAdminOrModeratorUsers(RoleEnum role) { 40 | User otherUser = new User(9L); 41 | otherUser.setRole(role); 42 | User user = new User(1L); 43 | user.setRole(RoleEnum.ROLE_USER); 44 | Post post = new Post(); 45 | post.setId(2L); 46 | post.setCreatedBy(user); 47 | 48 | assertThat(post.canEditByUser(otherUser)).isTrue(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /e2e-tests/src/test/java/com/sivalabs/techbuzz/BaseTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import com.microsoft.playwright.Browser; 4 | import com.microsoft.playwright.BrowserContext; 5 | import com.microsoft.playwright.BrowserType; 6 | import com.microsoft.playwright.Page; 7 | import com.microsoft.playwright.Playwright; 8 | import com.sivalabs.techbuzz.config.ConfigLoader; 9 | import com.sivalabs.techbuzz.config.Configuration; 10 | import org.junit.jupiter.api.AfterAll; 11 | import org.junit.jupiter.api.AfterEach; 12 | import org.junit.jupiter.api.BeforeAll; 13 | import org.junit.jupiter.api.BeforeEach; 14 | 15 | public abstract class BaseTest { 16 | static Playwright playwright; 17 | static Browser browser; 18 | static Configuration configuration; 19 | static String rootUrl; 20 | 21 | // New instance for each test method. 22 | BrowserContext context; 23 | Page page; 24 | 25 | @BeforeAll 26 | static void launchBrowser() { 27 | configuration = ConfigLoader.loadConfiguration(); 28 | playwright = Playwright.create(); 29 | browser = playwright 30 | .chromium() 31 | .launch(new BrowserType.LaunchOptions() 32 | .setHeadless(configuration.isHeadlessMode()) 33 | .setSlowMo(configuration.getSlowMo())); 34 | rootUrl = configuration.getRootUrl(); 35 | } 36 | 37 | @BeforeEach 38 | void createContextAndPage() { 39 | context = browser.newContext(new Browser.NewContextOptions().setViewportSize(1920, 850)); 40 | page = context.newPage(); 41 | } 42 | 43 | @AfterEach 44 | void closeContext() { 45 | context.close(); 46 | } 47 | 48 | @AfterAll 49 | static void closeBrowser() { 50 | playwright.close(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/mappers/PostMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.mappers; 2 | 3 | import com.sivalabs.techbuzz.posts.domain.dtos.PostViewDTO; 4 | import com.sivalabs.techbuzz.posts.domain.models.Category; 5 | import com.sivalabs.techbuzz.posts.domain.models.Post; 6 | import com.sivalabs.techbuzz.posts.domain.models.Vote; 7 | import com.sivalabs.techbuzz.users.domain.dtos.UserDTO; 8 | import com.sivalabs.techbuzz.users.domain.mappers.UserDTOMapper; 9 | import com.sivalabs.techbuzz.users.domain.models.User; 10 | import java.util.Set; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class PostMapper { 15 | private final UserDTOMapper userDTOMapper; 16 | 17 | public PostMapper(UserDTOMapper userDTOMapper) { 18 | this.userDTOMapper = userDTOMapper; 19 | } 20 | 21 | public PostViewDTO toPostViewDTO(User loginUser, Post post) { 22 | if (post == null) { 23 | return null; 24 | } 25 | Category category = post.getCategory(); 26 | Set voteS = post.getVotes(); 27 | UserDTO user = userDTOMapper.toDTO(post.getCreatedBy()); 28 | boolean editable = post.canEditByUser(loginUser); 29 | boolean upVoted = post.isUpVotedByUser(loginUser); 30 | boolean downVoted = post.isDownVotedByUser(loginUser); 31 | return new PostViewDTO( 32 | post.getId(), 33 | post.getTitle(), 34 | post.getUrl(), 35 | post.getContent(), 36 | category, 37 | voteS, 38 | user, 39 | post.getCreatedAt(), 40 | post.getUpdatedAt(), 41 | editable, 42 | upVoted, 43 | downVoted); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/email/verify-email.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | TechBuzz - Verify Email 10 | 11 | 14 | 15 | 16 |
17 | 22 |
23 |
24 |
25 |
26 |
27 |

Hi username,

28 |

Please click on the below link to verify your account.

29 |

Verify 30 | Email

31 |

32 | Thanks,
33 | TechBuzz Team 34 |

35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |

© TechBuzz 2023

43 |
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/email/password-reset.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | TechBuzz - Verify Email 10 | 11 | 14 | 15 | 16 |
17 | 22 |
23 |
24 |
25 |
26 |
27 |

Hi username,

28 |

Please click on the below link to change your password.

29 |

Verify 30 | Email

31 |

32 | Thanks,
33 | TechBuzz Team 34 |

35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |

© TechBuzz 2023

43 |
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/AddVoteController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.config.annotations.AnyAuthenticatedUser; 4 | import com.sivalabs.techbuzz.config.annotations.CurrentUser; 5 | import com.sivalabs.techbuzz.config.logging.Loggable; 6 | import com.sivalabs.techbuzz.posts.domain.dtos.CreateVoteRequest; 7 | import com.sivalabs.techbuzz.posts.domain.dtos.PostViewDTO; 8 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 9 | import com.sivalabs.techbuzz.users.domain.models.User; 10 | import jakarta.validation.Valid; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.stereotype.Controller; 14 | import org.springframework.ui.Model; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestBody; 17 | 18 | @Controller 19 | @Loggable 20 | class AddVoteController { 21 | private static final Logger log = LoggerFactory.getLogger(AddVoteController.class); 22 | private final PostService postService; 23 | 24 | AddVoteController(PostService postService) { 25 | this.postService = postService; 26 | } 27 | 28 | @PostMapping("/partials/add-vote") 29 | @AnyAuthenticatedUser 30 | public String createVote(@Valid @RequestBody CreateVoteRequest request, @CurrentUser User loginUser, Model model) { 31 | var createVoteRequest = new CreateVoteRequest(request.postId(), loginUser.getId(), request.value()); 32 | postService.addVote(createVoteRequest); 33 | log.info("Vote added by User id: {} for Post id: {}", loginUser.getId(), request.postId()); 34 | PostViewDTO post = postService.getPostViewDTO(request.postId()); 35 | model.addAttribute("post", post); 36 | return "fragments/post"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/models/Vote.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | public class Vote { 6 | private Long id; 7 | private Long userId; 8 | private Long postId; 9 | private Integer value; 10 | protected LocalDateTime createdAt; 11 | protected LocalDateTime updatedAt; 12 | 13 | public Vote() {} 14 | 15 | public Vote(Long id, Long userId, Long postId, Integer value, LocalDateTime createdAt, LocalDateTime updatedAt) { 16 | this.id = id; 17 | this.userId = userId; 18 | this.postId = postId; 19 | this.value = value; 20 | this.createdAt = createdAt; 21 | this.updatedAt = updatedAt; 22 | } 23 | 24 | public void setId(Long id) { 25 | this.id = id; 26 | } 27 | 28 | public void setUserId(Long userId) { 29 | this.userId = userId; 30 | } 31 | 32 | public void setPostId(Long postId) { 33 | this.postId = postId; 34 | } 35 | 36 | public void setValue(Integer value) { 37 | this.value = value; 38 | } 39 | 40 | public void setCreatedAt(LocalDateTime createdAt) { 41 | this.createdAt = createdAt; 42 | } 43 | 44 | public void setUpdatedAt(LocalDateTime updatedAt) { 45 | this.updatedAt = updatedAt; 46 | } 47 | 48 | public Long getId() { 49 | return this.id; 50 | } 51 | 52 | public Long getUserId() { 53 | return this.userId; 54 | } 55 | 56 | public Long getPostId() { 57 | return this.postId; 58 | } 59 | 60 | public Integer getValue() { 61 | return this.value; 62 | } 63 | 64 | public LocalDateTime getCreatedAt() { 65 | return this.createdAt; 66 | } 67 | 68 | public LocalDateTime getUpdatedAt() { 69 | return this.updatedAt; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /e2e-tests/src/test/java/com/sivalabs/techbuzz/config/Configuration.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config; 2 | 3 | public class Configuration { 4 | private boolean headlessMode; 5 | private double slowMo; 6 | private String rootUrl; 7 | private String normalUserEmail; 8 | private String normalUserPassword; 9 | private String adminUserEmail; 10 | private String adminUserPassword; 11 | 12 | public boolean isHeadlessMode() { 13 | return headlessMode; 14 | } 15 | 16 | public void setHeadlessMode(boolean headlessMode) { 17 | this.headlessMode = headlessMode; 18 | } 19 | 20 | public double getSlowMo() { 21 | return slowMo; 22 | } 23 | 24 | public void setSlowMo(double slowMo) { 25 | this.slowMo = slowMo; 26 | } 27 | 28 | public String getRootUrl() { 29 | return rootUrl; 30 | } 31 | 32 | public void setRootUrl(String rootUrl) { 33 | this.rootUrl = rootUrl; 34 | } 35 | 36 | public String getNormalUserEmail() { 37 | return normalUserEmail; 38 | } 39 | 40 | public void setNormalUserEmail(String normalUserEmail) { 41 | this.normalUserEmail = normalUserEmail; 42 | } 43 | 44 | public String getNormalUserPassword() { 45 | return normalUserPassword; 46 | } 47 | 48 | public void setNormalUserPassword(String normalUserPassword) { 49 | this.normalUserPassword = normalUserPassword; 50 | } 51 | 52 | public String getAdminUserEmail() { 53 | return adminUserEmail; 54 | } 55 | 56 | public void setAdminUserEmail(String adminUserEmail) { 57 | this.adminUserEmail = adminUserEmail; 58 | } 59 | 60 | public String getAdminUserPassword() { 61 | return adminUserPassword; 62 | } 63 | 64 | public void setAdminUserPassword(String adminUserPassword) { 65 | this.adminUserPassword = adminUserPassword; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/jobs/NewPostsNotificationJob.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.jobs; 2 | 3 | import com.sivalabs.techbuzz.ApplicationProperties; 4 | import com.sivalabs.techbuzz.posts.domain.models.Post; 5 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 6 | import com.sivalabs.techbuzz.users.domain.services.UserService; 7 | import java.time.LocalDateTime; 8 | import java.time.LocalTime; 9 | import java.util.List; 10 | import java.util.stream.Collectors; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.scheduling.annotation.Scheduled; 14 | import org.springframework.stereotype.Component; 15 | 16 | @Component 17 | public class NewPostsNotificationJob { 18 | 19 | private static final Logger log = LoggerFactory.getLogger(NewPostsNotificationJob.class); 20 | 21 | private final PostService postService; 22 | private final UserService userService; 23 | private final ApplicationProperties properties; 24 | 25 | public NewPostsNotificationJob(PostService postService, UserService userService, ApplicationProperties properties) { 26 | this.postService = postService; 27 | this.userService = userService; 28 | this.properties = properties; 29 | } 30 | 31 | @Scheduled(cron = "${techbuzz.new-posts-notification-frequency}") 32 | public void notifyUsersAboutNewPosts() { 33 | LocalDateTime createdDateFrom = 34 | LocalDateTime.now().with(LocalTime.MIDNIGHT).minusDays(properties.newPostsAgeInDays()); 35 | List posts = postService.findPostCreatedFrom(createdDateFrom); 36 | if (posts.size() > 0) { 37 | List emailIds = userService.findVerifiedUsersMailIds(); 38 | if (emailIds.size() > 0) { 39 | postService.sendNewPostsNotification(posts, emailIds.stream().collect(Collectors.joining(","))); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/posts/category.html: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | Category 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 24 |
25 |
26 |

Category Description

27 |
28 |
29 |
30 |
31 |
32 | 33 |
34 | 38 |
39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/DeletePostControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | 7 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 8 | import com.sivalabs.techbuzz.posts.domain.dtos.CreatePostRequest; 9 | import com.sivalabs.techbuzz.posts.domain.models.Category; 10 | import com.sivalabs.techbuzz.posts.domain.models.Post; 11 | import com.sivalabs.techbuzz.posts.domain.services.CategoryService; 12 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 13 | import com.sivalabs.techbuzz.security.SecurityService; 14 | import com.sivalabs.techbuzz.users.domain.models.User; 15 | import org.junit.jupiter.api.Test; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.security.test.context.support.WithUserDetails; 18 | 19 | class DeletePostControllerTests extends AbstractIntegrationTest { 20 | @Autowired 21 | CategoryService categoryService; 22 | 23 | @Autowired 24 | PostService postService; 25 | 26 | @Autowired 27 | SecurityService securityService; 28 | 29 | @Test 30 | @WithUserDetails(value = ADMIN_EMAIL) 31 | void shouldDeletePost() throws Exception { 32 | Category category = categoryService.getCategory("java"); 33 | User user = securityService.loginUser(); 34 | CreatePostRequest request = 35 | new CreatePostRequest("title", "https://sivalabs.in", "test content", category.getId(), user.getId()); 36 | Post post = postService.createPost(request); 37 | mockMvc.perform(delete("/posts/{id}", post.getId()).with(csrf())).andExpect(status().isOk()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/PostsByUserController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.model.PagedResult; 4 | import com.sivalabs.techbuzz.config.logging.Loggable; 5 | import com.sivalabs.techbuzz.posts.domain.dtos.PostViewDTO; 6 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.stereotype.Controller; 10 | import org.springframework.ui.Model; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.RequestParam; 14 | 15 | @Controller 16 | @Loggable 17 | public class PostsByUserController { 18 | private static final Logger log = LoggerFactory.getLogger(PostsByUserController.class); 19 | 20 | private final PostService postService; 21 | 22 | public PostsByUserController(PostService postService) { 23 | this.postService = postService; 24 | } 25 | 26 | @GetMapping("/users/{userId}/posts/{tab}") 27 | public String getUserSpecificPosts( 28 | @PathVariable(name = "userId") Long userId, 29 | @PathVariable(name = "tab") String tab, 30 | @RequestParam(name = "page", defaultValue = "1") Integer page, 31 | Model model) { 32 | log.info("Fetching created posts for user {} with page: {}", userId, page); 33 | PagedResult data = "created".equals(tab) 34 | ? postService.getCreatedPostsByUser(userId, page) 35 | : postService.getVotedPostsByUser(userId, page); 36 | String commonUrl = "/users/" + userId + "/posts/"; 37 | model.addAttribute("paginationPrefix", commonUrl + tab + "?"); 38 | model.addAttribute("postsData", data); 39 | model.addAttribute("currentTab", tab); 40 | 41 | return "fragments/user-posts"; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/forgotPassword.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Resend Verification Email 9 | 10 | 11 |
12 | 13 |
14 | 15 |
16 |
17 |

Forgot Password

18 |
19 |
20 |
22 | 25 | 28 |
29 | 30 | 35 |
Email Error
36 |
37 | 38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/resendVerification.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Resend Verification Email 9 | 10 | 11 |
12 | 13 |
14 | 15 |
16 |
17 |

Resend Verification Email

18 |
19 |
20 |
22 | 25 | 28 |
29 | 30 | 35 |
Email Error
36 |
37 | 38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/DeletePostController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.exceptions.UnauthorisedAccessException; 4 | import com.sivalabs.techbuzz.config.annotations.AnyAuthenticatedUser; 5 | import com.sivalabs.techbuzz.config.annotations.CurrentUser; 6 | import com.sivalabs.techbuzz.config.logging.Loggable; 7 | import com.sivalabs.techbuzz.posts.domain.models.Post; 8 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 9 | import com.sivalabs.techbuzz.users.domain.models.User; 10 | import java.util.Objects; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.stereotype.Controller; 15 | import org.springframework.web.bind.annotation.DeleteMapping; 16 | import org.springframework.web.bind.annotation.PathVariable; 17 | import org.springframework.web.bind.annotation.ResponseStatus; 18 | 19 | @Controller 20 | @Loggable 21 | public class DeletePostController { 22 | private static final Logger log = LoggerFactory.getLogger(DeletePostController.class); 23 | private final PostService postService; 24 | 25 | public DeletePostController(PostService postService) { 26 | this.postService = postService; 27 | } 28 | 29 | @DeleteMapping("/posts/{id}") 30 | @ResponseStatus 31 | @AnyAuthenticatedUser 32 | public ResponseEntity deletePost(@PathVariable Long id, @CurrentUser User loginUser) { 33 | Post post = postService.getPost(id); 34 | this.checkPrivilege(post, loginUser); 35 | postService.deletePost(id); 36 | log.info("Remove Post with id {}", id); 37 | return ResponseEntity.ok().build(); 38 | } 39 | 40 | private void checkPrivilege(Post post, User loginUser) { 41 | if (!(Objects.equals(post.getCreatedBy().getId(), loginUser.getId()) || loginUser.isAdminOrModerator())) { 42 | throw new UnauthorisedAccessException("Permission Denied"); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/logging/LoggingAspect.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config.logging; 2 | 3 | import org.aspectj.lang.ProceedingJoinPoint; 4 | import org.aspectj.lang.annotation.Around; 5 | import org.aspectj.lang.annotation.Aspect; 6 | import org.aspectj.lang.annotation.Pointcut; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.stereotype.Component; 10 | 11 | @Aspect 12 | @Component 13 | public class LoggingAspect { 14 | 15 | private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class); 16 | 17 | @Pointcut("within(@org.springframework.stereotype.Repository *)" 18 | + " || within(@org.springframework.stereotype.Service *)" 19 | + " || within(@org.springframework.stereotype.Controller *)" 20 | + " || within(@org.springframework.web.bind.annotation.RestController *)") 21 | public void springBeanPointcut() { 22 | // pointcut definition 23 | } 24 | 25 | @Pointcut("@within(com.sivalabs.techbuzz.config.logging.Loggable) || " 26 | + "@annotation(com.sivalabs.techbuzz.config.logging.Loggable)") 27 | public void applicationPackagePointcut() { 28 | // pointcut definition 29 | } 30 | 31 | @Around("applicationPackagePointcut()") 32 | public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { 33 | if (logger.isDebugEnabled()) { 34 | logger.debug( 35 | "Enter: {}.{}()", 36 | joinPoint.getSignature().getDeclaringTypeName(), 37 | joinPoint.getSignature().getName()); 38 | } 39 | long start = System.currentTimeMillis(); 40 | Object result = joinPoint.proceed(); 41 | long end = System.currentTimeMillis(); 42 | if (logger.isDebugEnabled()) { 43 | logger.debug( 44 | "Exit: {}.{}(). Time taken: {} millis", 45 | joinPoint.getSignature().getDeclaringTypeName(), 46 | joinPoint.getSignature().getName(), 47 | end - start); 48 | } 49 | return result; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/adapter/repositories/JooqCategoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.adapter.repositories; 2 | 3 | import static com.sivalabs.techbuzz.jooq.tables.Categories.CATEGORIES; 4 | 5 | import com.sivalabs.techbuzz.jooq.tables.records.CategoriesRecord; 6 | import com.sivalabs.techbuzz.posts.domain.models.Category; 7 | import com.sivalabs.techbuzz.posts.domain.repositories.CategoryRepository; 8 | import java.util.List; 9 | import java.util.Optional; 10 | import org.jooq.DSLContext; 11 | import org.jooq.RecordMapper; 12 | import org.springframework.stereotype.Repository; 13 | 14 | @Repository 15 | class JooqCategoryRepository implements CategoryRepository { 16 | private final DSLContext dsl; 17 | 18 | JooqCategoryRepository(DSLContext dsl) { 19 | this.dsl = dsl; 20 | } 21 | 22 | @Override 23 | public Optional findBySlug(String slug) { 24 | return this.dsl 25 | .selectFrom(CATEGORIES) 26 | .where(CATEGORIES.SLUG.eq(slug)) 27 | .fetchOptional(CategoryRecordMapper.INSTANCE); 28 | } 29 | 30 | @Override 31 | public Category findById(Long id) { 32 | return this.dsl.selectFrom(CATEGORIES).where(CATEGORIES.ID.eq(id)).fetchSingle(CategoryRecordMapper.INSTANCE); 33 | } 34 | 35 | @Override 36 | public List findAll() { 37 | return this.dsl.selectFrom(CATEGORIES).orderBy(CATEGORIES.DISPLAY_ORDER).fetch(CategoryRecordMapper.INSTANCE); 38 | } 39 | 40 | static class CategoryRecordMapper implements RecordMapper { 41 | static final CategoryRecordMapper INSTANCE = new CategoryRecordMapper(); 42 | 43 | private CategoryRecordMapper() {} 44 | 45 | @Override 46 | public Category map(CategoriesRecord r) { 47 | return new Category( 48 | r.getId(), 49 | r.getName(), 50 | r.getSlug(), 51 | r.getDescription(), 52 | r.getImage(), 53 | r.getDisplayOrder(), 54 | r.getCreatedAt(), 55 | r.getUpdatedAt()); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /techbuzz/src/main/jooq/com/sivalabs/techbuzz/jooq/Public.java: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is generated by jOOQ. 3 | */ 4 | package com.sivalabs.techbuzz.jooq; 5 | 6 | 7 | import com.sivalabs.techbuzz.jooq.tables.Categories; 8 | import com.sivalabs.techbuzz.jooq.tables.Posts; 9 | import com.sivalabs.techbuzz.jooq.tables.Users; 10 | import com.sivalabs.techbuzz.jooq.tables.Votes; 11 | 12 | import java.util.Arrays; 13 | import java.util.List; 14 | 15 | import org.jooq.Catalog; 16 | import org.jooq.Sequence; 17 | import org.jooq.Table; 18 | import org.jooq.impl.SchemaImpl; 19 | 20 | 21 | /** 22 | * This class is generated by jOOQ. 23 | */ 24 | @SuppressWarnings({ "all", "unchecked", "rawtypes" }) 25 | public class Public extends SchemaImpl { 26 | 27 | private static final long serialVersionUID = 1L; 28 | 29 | /** 30 | * The reference instance of public 31 | */ 32 | public static final Public PUBLIC = new Public(); 33 | 34 | /** 35 | * The table public.categories. 36 | */ 37 | public final Categories CATEGORIES = Categories.CATEGORIES; 38 | 39 | /** 40 | * The table public.posts. 41 | */ 42 | public final Posts POSTS = Posts.POSTS; 43 | 44 | /** 45 | * The table public.users. 46 | */ 47 | public final Users USERS = Users.USERS; 48 | 49 | /** 50 | * The table public.votes. 51 | */ 52 | public final Votes VOTES = Votes.VOTES; 53 | 54 | /** 55 | * No further instances allowed 56 | */ 57 | private Public() { 58 | super("public", null); 59 | } 60 | 61 | 62 | @Override 63 | public Catalog getCatalog() { 64 | return DefaultCatalog.DEFAULT_CATALOG; 65 | } 66 | 67 | @Override 68 | public final List> getSequences() { 69 | return Arrays.asList( 70 | Sequences.POST_ID_SEQ, 71 | Sequences.USER_ID_SEQ, 72 | Sequences.VOTE_ID_SEQ 73 | ); 74 | } 75 | 76 | @Override 77 | public final List> getTables() { 78 | return Arrays.asList( 79 | Categories.CATEGORIES, 80 | Posts.POSTS, 81 | Users.USERS, 82 | Votes.VOTES 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/fragments/view-only-post.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
8 |
9 |
10 |

11 | 12 |

13 |

14 |

15 | Posted By: Name 18 | Date: Date 20 | Category: Category 22 |
23 |
24 | Post content 25 |
26 | 27 |

28 | 29 | 33 | 34 | 36 | 40 | 41 |

42 |
43 |
44 |
45 | 46 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/db/migration/V1__create_tables.sql: -------------------------------------------------------------------------------- 1 | create sequence user_id_seq start with 1 increment by 5; 2 | create sequence post_id_seq start with 1 increment by 5; 3 | create sequence vote_id_seq start with 1 increment by 5; 4 | 5 | create table users 6 | ( 7 | id bigint DEFAULT nextval('user_id_seq') not null, 8 | email varchar not null, 9 | password varchar not null, 10 | name varchar not null, 11 | role varchar not null, 12 | verified bool not null default false, 13 | verification_token varchar, 14 | created_at timestamp, 15 | updated_at timestamp, 16 | primary key (id), 17 | CONSTRAINT user_email_unique UNIQUE (email) 18 | ); 19 | 20 | create table categories 21 | ( 22 | id bigint not null, 23 | name varchar not null, 24 | slug varchar not null, 25 | description varchar not null, 26 | image varchar, 27 | display_order integer not null, 28 | created_at timestamp, 29 | updated_at timestamp, 30 | primary key (id), 31 | CONSTRAINT category_name_unique UNIQUE (name), 32 | CONSTRAINT category_slug_unique UNIQUE (slug) 33 | ); 34 | 35 | create table posts 36 | ( 37 | id bigint DEFAULT nextval('post_id_seq') not null, 38 | title varchar not null, 39 | url varchar, 40 | content text not null, 41 | created_by bigint not null REFERENCES users (id), 42 | cat_id bigint not null REFERENCES categories (id), 43 | created_at timestamp, 44 | updated_at timestamp, 45 | primary key (id) 46 | ); 47 | 48 | create table votes 49 | ( 50 | id bigint DEFAULT nextval('vote_id_seq') not null, 51 | user_id bigint not null REFERENCES users (id), 52 | post_id bigint not null REFERENCES posts (id), 53 | val integer not null, 54 | created_at timestamp, 55 | updated_at timestamp, 56 | primary key (id), 57 | UNIQUE (user_id, post_id) 58 | ); -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/config/argresolvers/CurrentUserArgumentResolver.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.config.argresolvers; 2 | 3 | import com.sivalabs.techbuzz.config.annotations.CurrentUser; 4 | import com.sivalabs.techbuzz.security.SecurityService; 5 | import java.lang.annotation.Annotation; 6 | import org.springframework.core.MethodParameter; 7 | import org.springframework.core.annotation.AnnotationUtils; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.web.bind.support.WebDataBinderFactory; 10 | import org.springframework.web.context.request.NativeWebRequest; 11 | import org.springframework.web.method.support.HandlerMethodArgumentResolver; 12 | import org.springframework.web.method.support.ModelAndViewContainer; 13 | 14 | @Component 15 | public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { 16 | 17 | private final SecurityService securityService; 18 | 19 | public CurrentUserArgumentResolver(SecurityService securityService) { 20 | this.securityService = securityService; 21 | } 22 | 23 | @Override 24 | public boolean supportsParameter(MethodParameter methodParameter) { 25 | return findMethodAnnotation(CurrentUser.class, methodParameter) != null; 26 | } 27 | 28 | @Override 29 | public Object resolveArgument( 30 | MethodParameter methodParameter, 31 | ModelAndViewContainer modelAndViewContainer, 32 | NativeWebRequest nativeWebRequest, 33 | WebDataBinderFactory webDataBinderFactory) { 34 | return securityService.loginUser(); 35 | } 36 | 37 | private T findMethodAnnotation(Class annotationClass, MethodParameter parameter) { 38 | T annotation = parameter.getParameterAnnotation(annotationClass); 39 | if (annotation != null) { 40 | return annotation; 41 | } 42 | Annotation[] annotationsToSearch = parameter.getParameterAnnotations(); 43 | for (Annotation toSearch : annotationsToSearch) { 44 | annotation = AnnotationUtils.findAnnotation(toSearch.annotationType(), annotationClass); 45 | if (annotation != null) { 46 | return annotation; 47 | } 48 | } 49 | return null; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/security/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.security; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 6 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 8 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 9 | import org.springframework.security.crypto.password.PasswordEncoder; 10 | import org.springframework.security.web.SecurityFilterChain; 11 | import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 12 | 13 | @Configuration 14 | @EnableWebSecurity 15 | @EnableMethodSecurity 16 | public class WebSecurityConfig { 17 | 18 | private static final String[] PUBLIC_RESOURCES = { 19 | "/webjars/**", 20 | "/resources/**", 21 | "/static/**", 22 | "/assets/**", 23 | "/favicon.ico", 24 | "/", 25 | "/error", 26 | "/403", 27 | "/404", 28 | "/actuator/**", 29 | "/api/categories", 30 | "/login", 31 | "/registration", 32 | "/registrationStatus", 33 | "/verify-email", 34 | "/c/**", 35 | "/users/**", 36 | "/resendVerification", 37 | "/forgot-password", 38 | "/reset-password" 39 | }; 40 | 41 | @Bean 42 | public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 43 | http.authorizeHttpRequests(c -> 44 | c.requestMatchers(PUBLIC_RESOURCES).permitAll().anyRequest().authenticated()); 45 | http.formLogin(c -> c.loginPage("/login") 46 | .defaultSuccessUrl("/") 47 | .failureUrl("/login?error") 48 | .permitAll()); 49 | http.logout(c -> c.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) 50 | .permitAll() 51 | .logoutSuccessUrl("/")); 52 | return http.build(); 53 | } 54 | 55 | @Bean 56 | public PasswordEncoder passwordEncoder() { 57 | return new BCryptPasswordEncoder(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/users/web/controllers/ForgotPasswordControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 5 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 7 | 8 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 9 | import org.junit.jupiter.api.Test; 10 | 11 | public class ForgotPasswordControllerTest extends AbstractIntegrationTest { 12 | @Test 13 | void shouldShowForgotPasswordPage() throws Exception { 14 | mockMvc.perform(get("/forgot-password")) 15 | .andExpect(status().isOk()) 16 | .andExpect(view().name("users/forgotPassword")) 17 | .andExpect(model().attributeExists("forgotPassword")); 18 | } 19 | 20 | @Test 21 | void shouldSendPasswordResetMail() throws Exception { 22 | mockMvc.perform(post("/forgot-password").with(csrf()).param("email", ADMIN_EMAIL)) 23 | .andExpect(status().is3xxRedirection()) 24 | .andExpect(flash().attribute("message", "Password reset link is sent to your email")) 25 | .andExpect(header().string("Location", "/forgot-password")); 26 | } 27 | 28 | @Test 29 | void shouldRedisplayForgotPasswordPageWhenSubmittedInvalidData() throws Exception { 30 | mockMvc.perform(post("/forgot-password").with(csrf()).param("email", "")) 31 | .andExpect(model().hasErrors()) 32 | .andExpect(model().attributeHasFieldErrorCode("forgotPassword", "email", "NotBlank")) 33 | .andExpect(view().name("users/forgotPassword")); 34 | } 35 | 36 | @Test 37 | void shouldRedisplayPasswordPageWhenEmailNotExist() throws Exception { 38 | mockMvc.perform(post("/forgot-password").with(csrf()).param("email", "test@test.com")) 39 | .andExpect(model().hasErrors()) 40 | .andExpect(model().attributeHasFieldErrors("forgotPassword", "email")) 41 | .andExpect(model().attributeHasFieldErrorCode("forgotPassword", "email", "email.not.exist")) 42 | .andExpect(view().name("users/forgotPassword")); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/jobs/NewPostNotificationJobTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.jobs; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.Mockito.never; 5 | import static org.mockito.Mockito.verify; 6 | 7 | import com.sivalabs.techbuzz.ApplicationProperties; 8 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 9 | import com.sivalabs.techbuzz.posts.domain.models.Post; 10 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 11 | import com.sivalabs.techbuzz.users.domain.services.UserService; 12 | import java.util.List; 13 | import org.junit.jupiter.api.BeforeEach; 14 | import org.junit.jupiter.api.Test; 15 | import org.mockito.ArgumentCaptor; 16 | import org.mockito.Captor; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | import org.springframework.boot.test.mock.mockito.SpyBean; 19 | import org.springframework.test.context.jdbc.Sql; 20 | 21 | public class NewPostNotificationJobTest extends AbstractIntegrationTest { 22 | 23 | private NewPostsNotificationJob job; 24 | 25 | @SpyBean 26 | UserService userService; 27 | 28 | @SpyBean 29 | PostService postService; 30 | 31 | @Autowired 32 | ApplicationProperties properties; 33 | 34 | @Captor 35 | private ArgumentCaptor emailIds; 36 | 37 | @Captor 38 | private ArgumentCaptor> posts; 39 | 40 | @BeforeEach 41 | void setUp() { 42 | job = new NewPostsNotificationJob(postService, userService, properties); 43 | } 44 | 45 | @Test 46 | @Sql("/posts_created_before_a_month.sql") 47 | void shouldNotSendNotificationWhenNoNewPosts() { 48 | job = new NewPostsNotificationJob(postService, userService, properties); 49 | job.notifyUsersAboutNewPosts(); 50 | verify(postService, never()).sendNewPostsNotification(null, null); 51 | } 52 | 53 | @Test 54 | @Sql("/posts_with_mixed_date_range.sql") 55 | void shouldFetchOnlyPostsWithinTimelineDefined() { 56 | job = new NewPostsNotificationJob(postService, userService, properties); 57 | job.notifyUsersAboutNewPosts(); 58 | verify(postService).sendNewPostsNotification(posts.capture(), emailIds.capture()); 59 | String emailCaptorValue = emailIds.getValue(); 60 | assertThat(emailCaptorValue.contains(",")); 61 | List postsCaptorValue = posts.getValue(); 62 | assertThat(postsCaptorValue.size() == 2); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/users/web/controllers/EmailVerificationControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import static org.instancio.Select.field; 4 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 5 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 7 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 9 | 10 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 11 | import com.sivalabs.techbuzz.users.domain.dtos.CreateUserRequest; 12 | import com.sivalabs.techbuzz.users.domain.dtos.UserDTO; 13 | import com.sivalabs.techbuzz.users.domain.services.UserService; 14 | import java.util.UUID; 15 | import org.instancio.Instancio; 16 | import org.junit.jupiter.api.Test; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | 19 | class EmailVerificationControllerTest extends AbstractIntegrationTest { 20 | 21 | @Autowired 22 | UserService userService; 23 | 24 | @Test 25 | void shouldVerifyEmailSuccessfully() throws Exception { 26 | CreateUserRequest request = Instancio.of(CreateUserRequest.class) 27 | .set(field("email"), UUID.randomUUID() + "@gmail.com") 28 | .create(); 29 | UserDTO user = userService.createUser(request); 30 | mockMvc.perform(get("/verify-email") 31 | .with(csrf()) 32 | .param("email", request.email()) 33 | .param("token", user.verificationToken())) 34 | .andExpect(status().isOk()) 35 | .andExpect(model().attribute("success", true)) 36 | .andExpect(view().name("users/emailVerification")); 37 | } 38 | 39 | @Test 40 | void emailVerificationShouldFailWhenEmailAndTokenNotMatched() throws Exception { 41 | mockMvc.perform(get("/verify-email") 42 | .with(csrf()) 43 | .param("email", "dummy@mail.com") 44 | .param("token", "secretToken")) 45 | .andExpect(status().isOk()) 46 | .andExpect(model().attribute("success", false)) 47 | .andExpect(view().name("users/emailVerification")); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/domain/mappers/PostMapperTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.mappers; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import com.sivalabs.techbuzz.posts.domain.dtos.PostViewDTO; 6 | import com.sivalabs.techbuzz.posts.domain.models.Post; 7 | import com.sivalabs.techbuzz.posts.domain.models.Vote; 8 | import com.sivalabs.techbuzz.users.domain.mappers.UserDTOMapper; 9 | import com.sivalabs.techbuzz.users.domain.models.User; 10 | import java.util.Set; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | 14 | class PostMapperTest { 15 | UserDTOMapper userDTOMapper; 16 | PostMapper postMapper; 17 | 18 | @BeforeEach 19 | void setUp() { 20 | userDTOMapper = new UserDTOMapper(); 21 | postMapper = new PostMapper(userDTOMapper); 22 | } 23 | 24 | @Test 25 | void shouldNotBeDeterminedAsVotedIfUserNotLoggedIn() { 26 | User user = new User(1L); 27 | Post post = new Post(); 28 | post.setCreatedBy(user); 29 | PostViewDTO postViewDTO = postMapper.toPostViewDTO(null, post); 30 | assertThat(postViewDTO.downVoted()).isFalse(); 31 | assertThat(postViewDTO.upVoted()).isFalse(); 32 | } 33 | 34 | @Test 35 | void shouldDeterminePostIsNotVotedCorrectly() { 36 | User user = new User(1L); 37 | Post post = new Post(); 38 | post.setCreatedBy(user); 39 | PostViewDTO postViewDTO = postMapper.toPostViewDTO(user, post); 40 | assertThat(postViewDTO.downVoted()).isFalse(); 41 | } 42 | 43 | @Test 44 | void shouldDeterminePostIsUpVotedCorrectly() { 45 | User user = new User(1L); 46 | Post post = new Post(); 47 | post.setId(2L); 48 | post.setCreatedBy(user); 49 | post.setVotes(Set.of(new Vote(null, user.getId(), post.getId(), 1, null, null))); 50 | PostViewDTO postViewDTO = postMapper.toPostViewDTO(user, post); 51 | assertThat(postViewDTO.upVoted()).isTrue(); 52 | } 53 | 54 | @Test 55 | void shouldDeterminePostIsDownVotedCorrectly() { 56 | User user = new User(1L); 57 | Post post = new Post(); 58 | post.setId(2L); 59 | post.setCreatedBy(user); 60 | post.setVotes(Set.of(new Vote(null, user.getId(), post.getId(), -1, null, null))); 61 | PostViewDTO postViewDTO = postMapper.toPostViewDTO(user, post); 62 | assertThat(postViewDTO.downVoted()).isTrue(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/ArchUnitTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; 4 | import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields; 5 | import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noMethods; 6 | 7 | import com.tngtech.archunit.core.domain.JavaClasses; 8 | import com.tngtech.archunit.core.importer.ClassFileImporter; 9 | import com.tngtech.archunit.core.importer.ImportOption; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.params.ParameterizedTest; 12 | import org.junit.jupiter.params.provider.CsvSource; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | 15 | class ArchUnitTest { 16 | 17 | JavaClasses importedClasses = new ClassFileImporter() 18 | .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) 19 | .importPackages("com.sivalabs.techbuzz"); 20 | 21 | @Test 22 | void shouldNotUseFieldInjection() { 23 | noFields().should().beAnnotatedWith(Autowired.class).check(importedClasses); 24 | } 25 | 26 | @ParameterizedTest 27 | @CsvSource({"posts", "users"}) 28 | void domainShouldNotDependOnOtherPackages(String module) { 29 | noClasses() 30 | .that() 31 | .resideInAnyPackage("com.sivalabs.techbuzz." + module + ".domain..") 32 | .should() 33 | .dependOnClassesThat() 34 | .resideInAnyPackage( 35 | "com.sivalabs.techbuzz." + module + ".adapter..", "com.sivalabs.techbuzz." + module + ".web..") 36 | .because("Domain classes should not depend on web or adapter layer") 37 | .check(importedClasses); 38 | } 39 | 40 | @Test 41 | void shouldNotUseJunit4Classes() { 42 | JavaClasses classes = new ClassFileImporter().importPackages("com.sivalabs.techbuzz"); 43 | 44 | noClasses() 45 | .should() 46 | .accessClassesThat() 47 | .resideInAnyPackage("org.junit") 48 | .because("Tests should use Junit5 instead of Junit4") 49 | .check(classes); 50 | 51 | noMethods() 52 | .should() 53 | .beAnnotatedWith("org.junit.Test") 54 | .orShould() 55 | .beAnnotatedWith("org.junit.Ignore") 56 | .because("Tests should use Junit5 instead of Junit4") 57 | .check(classes); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/login.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Login 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |

Login into TechBuzz

17 |
18 |
19 | 22 |
23 |
Error Message
24 |
25 |
26 |
Success Message
27 |
28 |
29 |
You have been logged out.
30 |
31 |
32 |
33 |
34 | 35 | 37 |
38 | 39 |
40 | 41 | 43 |
44 | 45 |
46 |
47 | Account Verification Pending? Resend Verification Email 48 |
49 |
50 | Forgot Password 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/ViewCategoryController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.model.PagedResult; 4 | import com.sivalabs.techbuzz.config.logging.Loggable; 5 | import com.sivalabs.techbuzz.posts.domain.dtos.PostViewDTO; 6 | import com.sivalabs.techbuzz.posts.domain.models.Category; 7 | import com.sivalabs.techbuzz.posts.domain.services.CategoryService; 8 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.stereotype.Controller; 12 | import org.springframework.ui.Model; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.RequestParam; 16 | 17 | @Controller 18 | @Loggable 19 | public class ViewCategoryController { 20 | private static final Logger log = LoggerFactory.getLogger(ViewCategoryController.class); 21 | 22 | private final PostService postService; 23 | private final CategoryService categoryService; 24 | 25 | public ViewCategoryController(PostService postService, CategoryService categoryService) { 26 | this.postService = postService; 27 | this.categoryService = categoryService; 28 | } 29 | 30 | @GetMapping("/c/{categorySlug}") 31 | public String viewCategory( 32 | @PathVariable(name = "categorySlug") String categorySlug, 33 | @RequestParam(name = "page", defaultValue = "1") Integer page, 34 | Model model) { 35 | log.info("Fetching posts for category {} with page: {}", categorySlug, page); 36 | PagedResult data = postService.getPostsByCategorySlug(categorySlug, page); 37 | if (data.data().isEmpty() && (page > 1 && page > data.totalPages())) { 38 | log.warn( 39 | "No posts found for category: {}, page:{}. Redirecting to last page", 40 | categorySlug, 41 | data.totalPages()); 42 | 43 | return "redirect:/c/" + categorySlug + "?page=" + data.totalPages(); 44 | } 45 | Category category = categoryService.getCategory(categorySlug); 46 | model.addAttribute("category", category); 47 | model.addAttribute("paginationPrefix", "/c/" + categorySlug + "?"); 48 | model.addAttribute("postsData", data); 49 | model.addAttribute("categories", categoryService.getAllCategories()); 50 | return "posts/category"; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/static/assets/js/app.js: -------------------------------------------------------------------------------- 1 | 2 | function deletePost(id) 3 | { 4 | let yes = confirm("Are you sure to delete?"); 5 | if (yes) { 6 | let token = $("meta[name='_csrf']").attr("content"); 7 | let header = $("meta[name='_csrf_header']").attr("content"); 8 | $.ajax ({ 9 | url: '/posts/'+id, 10 | type: "DELETE", 11 | headers: { 12 | [header]: token 13 | }, 14 | success: function(responseData, status){ 15 | window.location.reload(); 16 | } 17 | }); 18 | } 19 | 20 | } 21 | 22 | function addVote(postId, vote) 23 | { 24 | let token = $("meta[name='_csrf']").attr("content"); 25 | let header = $("meta[name='_csrf_header']").attr("content"); 26 | $.ajax ({ 27 | url: '/partials/add-vote', 28 | type: "POST", 29 | headers: { 30 | [header]: token 31 | }, 32 | data: JSON.stringify({postId: postId, value: vote}), 33 | contentType: "application/json", 34 | success: function(responseData, status){ 35 | $("#post-container-"+postId).replaceWith(responseData); 36 | } 37 | }); 38 | } 39 | 40 | function initCategoriesAutoComplete(fieldSelector) 41 | { 42 | $.ajax ({ 43 | url: '/api/categories', 44 | type: "GET", 45 | dataType: "json", 46 | success: function(responseData){ 47 | $(fieldSelector).selectize({ 48 | maxItems: 1, 49 | valueField: 'id', 50 | labelField: 'name', 51 | searchField: 'name', 52 | options: responseData, 53 | create: false 54 | }) 55 | } 56 | }); 57 | } 58 | const getUserProfile = (userId) => { 59 | $.ajax({ 60 | url: '/api/userProfile/' + userId, 61 | type: "GET", 62 | dataType: "json", 63 | success: function(responseData) { 64 | $("#userNameTxt").innerHTML(responseData.name); 65 | $("#") 66 | } 67 | }); 68 | } 69 | 70 | const tabChangeHandler = (tabName, dataUrl) => { 71 | const contentPane = $("#" + tabName + "TabContent"); 72 | contentPane.load(dataUrl, function(result) { 73 | var tabElement = $('#' + tabName); 74 | var tab = new bootstrap.Tab(tabElement); 75 | tab.show(); 76 | }); 77 | 78 | } 79 | const checkPassword =()=>{ 80 | if($("#password").val()!==$("#confirmedPassword").val()){ 81 | $("#passwordMismatchError").show(); 82 | return false; 83 | } 84 | $("#passwordMismatchError").hide(); 85 | return true; 86 | } 87 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/notifications/JavaEmailService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.notifications; 2 | 3 | import com.sivalabs.techbuzz.ApplicationProperties; 4 | import com.sivalabs.techbuzz.common.exceptions.TechBuzzException; 5 | import jakarta.mail.internet.InternetAddress; 6 | import jakarta.mail.internet.MimeMessage; 7 | import java.util.Map; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.mail.javamail.JavaMailSender; 11 | import org.springframework.mail.javamail.MimeMessageHelper; 12 | import org.thymeleaf.TemplateEngine; 13 | import org.thymeleaf.context.Context; 14 | 15 | public class JavaEmailService implements EmailService { 16 | private static final Logger log = LoggerFactory.getLogger(JavaEmailService.class); 17 | 18 | private final JavaMailSender emailSender; 19 | private final TemplateEngine templateEngine; 20 | private final ApplicationProperties properties; 21 | 22 | public JavaEmailService( 23 | JavaMailSender emailSender, TemplateEngine templateEngine, ApplicationProperties properties) { 24 | this.emailSender = emailSender; 25 | this.templateEngine = templateEngine; 26 | this.properties = properties; 27 | } 28 | 29 | public void sendEmail(String template, Map params, String to, String subject) { 30 | 31 | sendEmail(template, params, to, subject, false); 32 | } 33 | 34 | @Override 35 | public void sendBroadcastEmail(String template, Map params, String recipient, String subject) { 36 | 37 | sendEmail(template, params, recipient, subject, true); 38 | } 39 | 40 | private void sendEmail( 41 | String template, Map params, String recipient, String subject, boolean broadcast) { 42 | try { 43 | Context context = new Context(); 44 | context.setVariables(params); 45 | String content = templateEngine.process(template, context); 46 | MimeMessage mimeMessage = emailSender.createMimeMessage(); 47 | MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8"); 48 | helper.setFrom(properties.adminEmail()); 49 | if (broadcast) helper.setBcc(InternetAddress.parse(recipient)); 50 | else helper.setTo(recipient); 51 | helper.setSubject(subject); 52 | helper.setText(content, true); 53 | emailSender.send(mimeMessage); 54 | log.info("Sent email using default email service"); 55 | } catch (Exception e) { 56 | throw new TechBuzzException("Error while sending email", e); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/PostsByUserControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 5 | 6 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 7 | import com.sivalabs.techbuzz.posts.domain.dtos.CreatePostRequest; 8 | import com.sivalabs.techbuzz.posts.domain.dtos.CreateVoteRequest; 9 | import com.sivalabs.techbuzz.posts.domain.models.Category; 10 | import com.sivalabs.techbuzz.posts.domain.models.Post; 11 | import com.sivalabs.techbuzz.posts.domain.services.CategoryService; 12 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 13 | import com.sivalabs.techbuzz.security.SecurityService; 14 | import com.sivalabs.techbuzz.users.domain.models.User; 15 | import org.junit.jupiter.api.BeforeEach; 16 | import org.junit.jupiter.params.ParameterizedTest; 17 | import org.junit.jupiter.params.provider.CsvSource; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | import org.springframework.security.test.context.support.WithUserDetails; 20 | 21 | public class PostsByUserControllerTest extends AbstractIntegrationTest { 22 | 23 | @Autowired 24 | CategoryService categoryService; 25 | 26 | @Autowired 27 | PostService postService; 28 | 29 | @Autowired 30 | SecurityService securityService; 31 | 32 | Post post = null; 33 | 34 | @BeforeEach 35 | void setUp() { 36 | Category category = categoryService.getCategory("java"); 37 | User user = securityService.loginUser(); 38 | CreatePostRequest request = 39 | new CreatePostRequest("title", "https://sivalabs.in", "test content", category.getId(), user.getId()); 40 | post = postService.createPost(request); 41 | CreateVoteRequest voteRequest = new CreateVoteRequest(post.getId(), user.getId(), 1); 42 | postService.addVote(voteRequest); 43 | } 44 | 45 | @ParameterizedTest 46 | @CsvSource(textBlock = """ 47 | 1,created 48 | 1,voted 49 | """) 50 | @WithUserDetails(value = ADMIN_EMAIL) 51 | void shouldFetchPostsByUser(Long userId, String tab) throws Exception { 52 | mockMvc.perform(get("/users/{userId}/posts/{tab}", userId, tab)) 53 | .andExpect(status().isOk()) 54 | .andExpect(view().name("fragments/user-posts")) 55 | .andExpect(model().attributeExists("paginationPrefix")) 56 | .andExpect(model().attributeExists("currentTab")) 57 | .andExpect(model().attributeExists("postsData")); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /gatling-tests/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | com.sivalabs.techbuzz 8 | gatling-tests 9 | 0.0.1 10 | gatling-tests 11 | TechBuzz Gatling Tests 12 | 13 | UTF-8 14 | 21 15 | ${java.version} 16 | ${java.version} 17 | ${java.version} 18 | 3.13.1 19 | 4.11.0 20 | 2.43.0 21 | 22 | 23 | 24 | 25 | io.gatling.highcharts 26 | gatling-charts-highcharts 27 | ${gatling.version} 28 | test 29 | 30 | 31 | org.apache.commons 32 | commons-lang3 33 | 3.17.0 34 | test 35 | 36 | 37 | com.typesafe 38 | config 39 | 1.4.3 40 | test 41 | 42 | 43 | 44 | 45 | 46 | 47 | io.gatling 48 | gatling-maven-plugin 49 | ${gatling-maven-plugin.version} 50 | 51 | true 52 | 53 | 54 | 55 | com.diffplug.spotless 56 | spotless-maven-plugin 57 | ${spotless.version} 58 | 59 | 60 | 61 | 62 | 63 | 2.39.0 64 | 65 | 66 | 67 | 68 | 69 | 70 | compile 71 | 72 | check 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/adapter/repositories/JooqVoteRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.adapter.repositories; 2 | 3 | import static com.sivalabs.techbuzz.common.model.SystemClock.dateTimeNow; 4 | import static com.sivalabs.techbuzz.jooq.tables.Votes.VOTES; 5 | 6 | import com.sivalabs.techbuzz.jooq.tables.records.VotesRecord; 7 | import com.sivalabs.techbuzz.posts.domain.models.Vote; 8 | import com.sivalabs.techbuzz.posts.domain.repositories.VoteRepository; 9 | import java.util.Optional; 10 | import org.jooq.DSLContext; 11 | import org.jooq.RecordMapper; 12 | import org.springframework.stereotype.Repository; 13 | 14 | @Repository 15 | class JooqVoteRepository implements VoteRepository { 16 | private final DSLContext dsl; 17 | 18 | JooqVoteRepository(DSLContext dsl) { 19 | this.dsl = dsl; 20 | } 21 | 22 | @Override 23 | public Optional findByPostIdAndUserId(Long postId, Long userId) { 24 | return this.dsl 25 | .selectFrom(VOTES) 26 | .where(VOTES.POST_ID.eq(postId).and(VOTES.USER_ID.eq(userId))) 27 | .fetchOptional(VoteRecordMapper.INSTANCE); 28 | } 29 | 30 | @Override 31 | public void deleteVotesForPost(Long postId) { 32 | this.dsl.deleteFrom(VOTES).where(VOTES.POST_ID.eq(postId)).execute(); 33 | } 34 | 35 | @Override 36 | public Vote save(Vote vote) { 37 | return this.dsl 38 | .insertInto(VOTES) 39 | .set(VOTES.USER_ID, vote.getUserId()) 40 | .set(VOTES.POST_ID, vote.getPostId()) 41 | .set(VOTES.VAL, vote.getValue()) 42 | .set(VOTES.CREATED_AT, dateTimeNow()) 43 | .returning() 44 | .fetchSingle(VoteRecordMapper.INSTANCE); 45 | } 46 | 47 | @Override 48 | public void update(Vote vote) { 49 | this.dsl 50 | .update(VOTES) 51 | .set(VOTES.VAL, vote.getValue()) 52 | .where(VOTES.USER_ID.eq(vote.getUserId()).and(VOTES.POST_ID.eq(vote.getPostId()))) 53 | .execute(); 54 | } 55 | 56 | static class VoteRecordMapper implements RecordMapper { 57 | static final VoteRecordMapper INSTANCE = new VoteRecordMapper(); 58 | 59 | private VoteRecordMapper() {} 60 | 61 | @Override 62 | public Vote map(VotesRecord r) { 63 | return new Vote( 64 | r.getId(), 65 | r.getUserId(), 66 | r.getPostId(), 67 | r.getVal() != null ? r.getVal() : null, 68 | r.getCreatedAt(), 69 | r.getUpdatedAt()); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /gatling-tests/src/test/java/techbuzz/PostCreationSimulation.java: -------------------------------------------------------------------------------- 1 | package techbuzz; 2 | 3 | import static io.gatling.javaapi.core.CoreDsl.*; 4 | import static io.gatling.javaapi.http.HttpDsl.http; 5 | import static utils.SimulationHelper.getConfig; 6 | 7 | import io.gatling.javaapi.core.ChainBuilder; 8 | import io.gatling.javaapi.core.FeederBuilder; 9 | import io.gatling.javaapi.core.ScenarioBuilder; 10 | import io.gatling.javaapi.core.Simulation; 11 | import io.gatling.javaapi.http.HttpProtocolBuilder; 12 | import utils.SimulationHelper; 13 | 14 | public class PostCreationSimulation extends Simulation { 15 | 16 | HttpProtocolBuilder httpProtocol = SimulationHelper.getHttpProtocolBuilder(); 17 | 18 | FeederBuilder postFeeder = csv("data/feeders/posts.csv").random(); 19 | FeederBuilder credentialsFeeder = 20 | csv("data/feeders/credentials.csv").random(); 21 | 22 | ChainBuilder login = feed(credentialsFeeder) 23 | .exec(http("Login Form") 24 | .get("/login") 25 | .check(css("input[name=_csrf]", "value").saveAs("csrf"))) 26 | .pause(1) 27 | .exec(http("Login") 28 | .post("/login") 29 | .formParam("_csrf", "#{csrf}") 30 | .formParam("username", "#{username}") 31 | .formParam("password", "#{password}")) 32 | .pause(1); 33 | 34 | ChainBuilder createPost = feed(postFeeder) 35 | .exec(http("New Post Form") 36 | .get("/posts/new") 37 | .check(css("input[name=_csrf]", "value").saveAs("csrf"))) 38 | .pause(1) 39 | .exec(http("Create New Post") 40 | .post("/posts") 41 | .formParam("_csrf", "#{csrf}") 42 | .formParam("url", "#{url}") 43 | .formParam("title", "#{title}") 44 | .formParam("content", "#{title}") 45 | .formParam("categoryId", "#{category}")) 46 | .pause(1); 47 | 48 | ChainBuilder createPostFlow = exec(login).pause(2).exec(createPost); 49 | 50 | // ScenarioBuilder scnCreatePost = 51 | // scenario("Create Post").during(Duration.ofMinutes(2), "Counter").on(createPostFlow); 52 | 53 | ScenarioBuilder scnCreatePost = scenario("Create Post").exec(createPostFlow); 54 | 55 | { 56 | setUp(scnCreatePost.injectOpen(rampUsers(getConfig().getInt("users")).during(10))) 57 | .protocols(httpProtocol) 58 | .assertions( 59 | global().responseTime().max().lt(800), 60 | global().successfulRequests().percent().is(100.0)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/web/controllers/CreatePostController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.config.annotations.AnyAuthenticatedUser; 4 | import com.sivalabs.techbuzz.config.annotations.CurrentUser; 5 | import com.sivalabs.techbuzz.config.logging.Loggable; 6 | import com.sivalabs.techbuzz.posts.domain.dtos.CreatePostRequest; 7 | import com.sivalabs.techbuzz.posts.domain.models.Post; 8 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 9 | import com.sivalabs.techbuzz.users.domain.models.User; 10 | import jakarta.validation.Valid; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.stereotype.Controller; 14 | import org.springframework.ui.Model; 15 | import org.springframework.validation.BindingResult; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | import org.springframework.web.bind.annotation.ModelAttribute; 18 | import org.springframework.web.bind.annotation.PostMapping; 19 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 20 | 21 | @Controller 22 | @Loggable 23 | public class CreatePostController { 24 | private static final Logger log = LoggerFactory.getLogger(CreatePostController.class); 25 | 26 | private static final String MODEL_ATTRIBUTE_POST = "post"; 27 | private final PostService postService; 28 | 29 | public CreatePostController(PostService postService) { 30 | this.postService = postService; 31 | } 32 | 33 | @GetMapping("/posts/new") 34 | @AnyAuthenticatedUser 35 | public String newPostForm(Model model) { 36 | log.info("New post form requested by user"); 37 | model.addAttribute(MODEL_ATTRIBUTE_POST, new CreatePostRequest("", "", "", null, null)); 38 | return "posts/add-post"; 39 | } 40 | 41 | @PostMapping("/posts") 42 | @AnyAuthenticatedUser 43 | public String createPost( 44 | @Valid @ModelAttribute(MODEL_ATTRIBUTE_POST) CreatePostRequest request, 45 | BindingResult bindingResult, 46 | @CurrentUser User loginUser, 47 | RedirectAttributes redirectAttributes) { 48 | if (bindingResult.hasErrors()) { 49 | return "posts/add-post"; 50 | } 51 | var createPostRequest = new CreatePostRequest( 52 | request.title(), request.url(), request.content(), request.categoryId(), loginUser.getId()); 53 | Post post = postService.createPost(createPostRequest); 54 | log.info("Post saved successfully with id: {}", post.getId()); 55 | redirectAttributes.addFlashAttribute("message", "Post saved successfully"); 56 | return "redirect:/c/" + post.getCategory().getSlug(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/AddVoteControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 6 | 7 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 8 | import com.sivalabs.techbuzz.posts.domain.dtos.CreatePostRequest; 9 | import com.sivalabs.techbuzz.posts.domain.models.Category; 10 | import com.sivalabs.techbuzz.posts.domain.models.Post; 11 | import com.sivalabs.techbuzz.posts.domain.services.CategoryService; 12 | import com.sivalabs.techbuzz.posts.domain.services.PostService; 13 | import com.sivalabs.techbuzz.security.SecurityService; 14 | import com.sivalabs.techbuzz.users.domain.models.User; 15 | import org.junit.jupiter.api.BeforeEach; 16 | import org.junit.jupiter.api.Test; 17 | import org.springframework.beans.factory.annotation.Autowired; 18 | import org.springframework.http.MediaType; 19 | import org.springframework.security.test.context.support.WithUserDetails; 20 | 21 | class AddVoteControllerTest extends AbstractIntegrationTest { 22 | @Autowired 23 | CategoryService categoryService; 24 | 25 | @Autowired 26 | PostService postService; 27 | 28 | @Autowired 29 | SecurityService securityService; 30 | 31 | Post post = null; 32 | 33 | @BeforeEach 34 | void setUp() { 35 | Category category = categoryService.getCategory("java"); 36 | User user = securityService.loginUser(); 37 | CreatePostRequest request = 38 | new CreatePostRequest("title", "https://sivalabs.in", "test content", category.getId(), user.getId()); 39 | post = postService.createPost(request); 40 | } 41 | 42 | @Test 43 | @WithUserDetails(value = ADMIN_EMAIL) 44 | void shouldUpVoteAndReturnPostFragment() throws Exception { 45 | User user = securityService.loginUser(); 46 | mockMvc.perform(post("/partials/add-vote") 47 | .with(csrf()) 48 | .contentType(MediaType.APPLICATION_JSON) 49 | .content( 50 | """ 51 | { 52 | "postId": "%d", 53 | "userId": "%d", 54 | "value": "1" 55 | } 56 | """ 57 | .formatted(post.getId(), user.getId()))) 58 | .andExpect(status().isOk()) 59 | .andExpect(model().attributeExists("post")) 60 | .andExpect(view().name("fragments/post")); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /e2e-tests/src/test/java/com/sivalabs/techbuzz/AuthenticatedUserActionsTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz; 2 | 3 | import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | 6 | import com.microsoft.playwright.Dialog; 7 | import com.microsoft.playwright.Locator; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class AuthenticatedUserActionsTests extends BaseTest { 11 | 12 | @Test 13 | void shouldViewHomePageAsLoggedInUser() { 14 | doLogin(configuration.getNormalUserEmail(), configuration.getNormalUserPassword()); 15 | } 16 | 17 | @Test 18 | void shouldAddNewPost() { 19 | doLogin(configuration.getNormalUserEmail(), configuration.getNormalUserPassword()); 20 | page.locator("a:has-text('Add')").click(); 21 | page.locator("#title").fill("SivaLabs"); 22 | page.locator("#url").fill("https://sivalabs.in"); 23 | page.locator("#categoryId-selectized").fill("java"); 24 | page.locator("#categoryId-selectized").press("Enter"); 25 | page.locator("#content").fill("My experiments with technology"); 26 | page.locator("button:has-text('Submit')").click(); 27 | page.waitForURL(rootUrl + "/c/java"); 28 | 29 | assertEquals(rootUrl + "/c/java", page.url()); 30 | assertEquals("TechBuzz - Java", page.title()); 31 | } 32 | 33 | @Test 34 | void shouldEditPost() { 35 | doLogin(configuration.getAdminUserEmail(), configuration.getAdminUserPassword()); 36 | page.navigate(rootUrl + "/c/java"); 37 | page.locator("a:has-text('Edit')").first().click(); 38 | page.locator("#url").fill("https://sivalabs.in"); 39 | page.locator("#title").fill("SivaLabs"); 40 | page.locator("#categoryId-selectized").fill("general"); 41 | page.locator("#categoryId-selectized").press("Enter"); 42 | page.locator("#content").fill("Learn, Practice, Teach"); 43 | page.locator("button:has-text('Submit')").click(); 44 | 45 | Locator locator = page.getByText("Post updated successfully"); 46 | assertThat(locator).isVisible(); 47 | } 48 | 49 | @Test 50 | void shouldDeletePost() { 51 | doLogin(configuration.getAdminUserEmail(), configuration.getAdminUserPassword()); 52 | page.navigate(rootUrl + "/c/java"); 53 | page.onDialog(Dialog::accept); 54 | page.locator("button:has-text('Delete')").first().click(); 55 | page.waitForURL(rootUrl + "/c/java"); 56 | } 57 | 58 | private void doLogin(String email, String password) { 59 | page.navigate(rootUrl + "/login"); 60 | page.locator("#username").fill(email); 61 | page.locator("#password").fill(password); 62 | page.locator("button:has-text('Login')").click(); 63 | page.waitForURL(rootUrl + "/"); 64 | assertEquals(rootUrl + "/", page.url()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/posts/domain/models/Category.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.domain.models; 2 | 3 | import jakarta.validation.constraints.NotEmpty; 4 | import java.time.LocalDateTime; 5 | 6 | public class Category { 7 | private Long id; 8 | 9 | @NotEmpty 10 | private String name; 11 | 12 | @NotEmpty 13 | private String slug; 14 | 15 | @NotEmpty 16 | private String description; 17 | 18 | private String image; 19 | private Integer displayOrder; 20 | 21 | protected LocalDateTime createdAt; 22 | 23 | protected LocalDateTime updatedAt; 24 | 25 | public Category() {} 26 | 27 | public Category(Long id) { 28 | this.id = id; 29 | } 30 | 31 | public Category( 32 | Long id, 33 | String name, 34 | String slug, 35 | String description, 36 | String image, 37 | Integer displayOrder, 38 | LocalDateTime createdAt, 39 | LocalDateTime updatedAt) { 40 | this.id = id; 41 | this.name = name; 42 | this.slug = slug; 43 | this.description = description; 44 | this.image = image; 45 | this.displayOrder = displayOrder; 46 | this.createdAt = createdAt; 47 | this.updatedAt = updatedAt; 48 | } 49 | 50 | public void setId(Long id) { 51 | this.id = id; 52 | } 53 | 54 | public void setName(String name) { 55 | this.name = name; 56 | } 57 | 58 | public void setSlug(String slug) { 59 | this.slug = slug; 60 | } 61 | 62 | public void setDescription(String description) { 63 | this.description = description; 64 | } 65 | 66 | public void setImage(String image) { 67 | this.image = image; 68 | } 69 | 70 | public void setDisplayOrder(Integer displayOrder) { 71 | this.displayOrder = displayOrder; 72 | } 73 | 74 | public void setCreatedAt(LocalDateTime createdAt) { 75 | this.createdAt = createdAt; 76 | } 77 | 78 | public void setUpdatedAt(LocalDateTime updatedAt) { 79 | this.updatedAt = updatedAt; 80 | } 81 | 82 | public Long getId() { 83 | return this.id; 84 | } 85 | 86 | public String getName() { 87 | return this.name; 88 | } 89 | 90 | public String getSlug() { 91 | return this.slug; 92 | } 93 | 94 | public String getDescription() { 95 | return this.description; 96 | } 97 | 98 | public String getImage() { 99 | return this.image; 100 | } 101 | 102 | public Integer getDisplayOrder() { 103 | return this.displayOrder; 104 | } 105 | 106 | public LocalDateTime getCreatedAt() { 107 | return this.createdAt; 108 | } 109 | 110 | public LocalDateTime getUpdatedAt() { 111 | return this.updatedAt; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/posts/web/controllers/CreatePostControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.posts.web.controllers; 2 | 3 | import static org.hamcrest.Matchers.matchesPattern; 4 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 5 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 6 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 7 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; 8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 9 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 10 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 11 | 12 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 13 | import org.junit.jupiter.api.Test; 14 | import org.springframework.security.test.context.support.WithUserDetails; 15 | 16 | class CreatePostControllerTest extends AbstractIntegrationTest { 17 | 18 | @Test 19 | @WithUserDetails(value = ADMIN_EMAIL) 20 | void shouldShowCreatePostFormPage() throws Exception { 21 | mockMvc.perform(get("/posts/new")) 22 | .andExpect(status().isOk()) 23 | .andExpect(view().name("posts/add-post")) 24 | .andExpect(model().attributeExists("post")); 25 | } 26 | 27 | @Test 28 | @WithUserDetails(value = ADMIN_EMAIL) 29 | void shouldCreatePostSuccessfully() throws Exception { 30 | mockMvc.perform(post("/posts") 31 | .with(csrf()) 32 | .param("url", "https://sivalabs.in") 33 | .param("title", "SivaLabs") 34 | .param("content", "demo content") 35 | .param("categoryId", "1")) 36 | .andExpect(status().is3xxRedirection()) 37 | .andExpect(flash().attribute("message", "Post saved successfully")) 38 | .andExpect(view().name(matchesPattern("redirect:/c/.*"))); 39 | } 40 | 41 | @Test 42 | @WithUserDetails(value = ADMIN_EMAIL) 43 | void shouldFailToCreatePostIfDataIsInvalid() throws Exception { 44 | mockMvc.perform(post("/posts") 45 | .with(csrf()) 46 | .param("url", "") 47 | .param("title", "") 48 | .param("content", "") 49 | .param("categoryId", "")) 50 | .andExpect(model().hasErrors()) 51 | .andExpect(model().attributeHasFieldErrorCode("post", "title", "NotEmpty")) 52 | .andExpect(model().attributeHasFieldErrorCode("post", "content", "NotEmpty")) 53 | .andExpect(model().attributeHasFieldErrorCode("post", "categoryId", "NotNull")) 54 | .andExpect(view().name("posts/add-post")); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | User Posts 12 | 13 | 14 |
15 |
16 |
17 |
18 |

Name

19 |
20 |
21 |
24 | Date 25 |
26 |
27 | 46 |
47 | 63 |
64 |
65 |
66 | 81 |
82 | 83 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/users/resetPassword.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Registration 9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 |
17 |
18 |

Password Reset Form

19 |
20 |
21 | 25 |
26 | 27 |
28 | 29 | 35 |
Email Error
36 |
37 |
38 | 39 | 44 |
Password Not matching
45 |
46 |
47 | 48 | 51 | 52 |
53 | 54 | 55 | 56 |
57 | 58 |
59 |
60 |
61 |
62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/web/controllers/PasswordResetController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.exceptions.ResourceNotFoundException; 4 | import com.sivalabs.techbuzz.config.logging.Loggable; 5 | import com.sivalabs.techbuzz.users.domain.dtos.PasswordResetRequest; 6 | import com.sivalabs.techbuzz.users.domain.services.UserService; 7 | import jakarta.validation.Valid; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.stereotype.Controller; 11 | import org.springframework.ui.Model; 12 | import org.springframework.validation.BindingResult; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.ModelAttribute; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestParam; 17 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 18 | 19 | @Controller 20 | @Loggable 21 | public class PasswordResetController { 22 | private static final Logger logger = LoggerFactory.getLogger(PasswordResetController.class); 23 | private static final String CHANGE_PASSWORD = "users/resetPassword"; 24 | 25 | private final UserService userService; 26 | 27 | public PasswordResetController(UserService userService) { 28 | this.userService = userService; 29 | } 30 | 31 | @GetMapping("/reset-password") 32 | public String passwordResetForm( 33 | Model model, 34 | @RequestParam("email") String email, 35 | @RequestParam("token") String token, 36 | RedirectAttributes redirectAttributes) { 37 | try { 38 | logger.info("Loading Reset Password form"); 39 | userService.verifyPasswordResetToken(email, token); 40 | PasswordResetRequest passwordResetRequest = new PasswordResetRequest(email, token, ""); 41 | model.addAttribute("resetPassword", passwordResetRequest); 42 | return CHANGE_PASSWORD; 43 | } catch (ResourceNotFoundException e) { 44 | logger.error("Error during updating password: {}", e.getMessage()); 45 | redirectAttributes.addFlashAttribute("errorMessage", "Password reset failed, please try again"); 46 | return "redirect:/reset-password"; 47 | } 48 | } 49 | 50 | @PostMapping("/reset-password") 51 | public String resetPassword( 52 | @ModelAttribute("resetPassword") @Valid PasswordResetRequest passwordResetRequest, 53 | BindingResult bindingResult, 54 | RedirectAttributes redirectAttributes) { 55 | 56 | if (bindingResult.hasErrors()) { 57 | return CHANGE_PASSWORD; 58 | } 59 | try { 60 | userService.changePassword(passwordResetRequest); 61 | redirectAttributes.addFlashAttribute("message", "Password reset is successful"); 62 | return "redirect:/login"; 63 | 64 | } catch (RuntimeException e) { 65 | logger.error("Error during updating password: {}", e.getMessage()); 66 | redirectAttributes.addFlashAttribute("errorMessage", "Password reset failed, please try again"); 67 | return "redirect:/reset-password"; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/web/controllers/ForgotPasswordController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import com.sivalabs.techbuzz.common.exceptions.ResourceNotFoundException; 4 | import com.sivalabs.techbuzz.config.logging.Loggable; 5 | import com.sivalabs.techbuzz.users.domain.dtos.ForgotPasswordRequest; 6 | import com.sivalabs.techbuzz.users.domain.dtos.UserDTO; 7 | import com.sivalabs.techbuzz.users.domain.services.UserService; 8 | import jakarta.servlet.http.HttpServletRequest; 9 | import jakarta.validation.Valid; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import org.springframework.stereotype.Controller; 13 | import org.springframework.ui.Model; 14 | import org.springframework.validation.BindingResult; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.ModelAttribute; 17 | import org.springframework.web.bind.annotation.PostMapping; 18 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 19 | import org.springframework.web.servlet.support.ServletUriComponentsBuilder; 20 | 21 | @Controller 22 | @Loggable 23 | public class ForgotPasswordController { 24 | private static final Logger logger = LoggerFactory.getLogger(ForgotPasswordController.class); 25 | 26 | private final UserService userService; 27 | private static final String FORGOT_PASSWORD = "users/forgotPassword"; 28 | 29 | public ForgotPasswordController(UserService userService) { 30 | this.userService = userService; 31 | } 32 | 33 | @GetMapping("/forgot-password") 34 | public String forgotPasswordForm(Model model) { 35 | logger.info("Loading Forgot Password Initiation"); 36 | model.addAttribute("forgotPassword", new ForgotPasswordRequest("")); 37 | return FORGOT_PASSWORD; 38 | } 39 | 40 | @PostMapping("/forgot-password") 41 | public String initiatePasswordReset( 42 | HttpServletRequest request, 43 | @Valid @ModelAttribute("forgotPassword") ForgotPasswordRequest forgotPasswordRequest, 44 | BindingResult bindingResult, 45 | RedirectAttributes redirectAttributes) { 46 | logger.info("Email in password {}", forgotPasswordRequest.email()); 47 | if (bindingResult.hasErrors()) { 48 | return FORGOT_PASSWORD; 49 | } 50 | 51 | try { 52 | UserDTO userDTO = userService.createPasswordResetToken(forgotPasswordRequest); 53 | this.sendResetPasswordMail(request, userDTO); 54 | logger.info("Sent password reset link to {}", forgotPasswordRequest.email()); 55 | redirectAttributes.addFlashAttribute("message", "Password reset link is sent to your email"); 56 | return "redirect:/forgot-password"; 57 | 58 | } catch (ResourceNotFoundException e) { 59 | logger.error("Error during sending password reset request error: {}", e.getMessage()); 60 | bindingResult.rejectValue("email", "email.not.exist", e.getMessage()); 61 | return FORGOT_PASSWORD; 62 | } 63 | } 64 | 65 | private void sendResetPasswordMail(HttpServletRequest request, UserDTO userDTO) { 66 | String baseUrl = ServletUriComponentsBuilder.fromRequestUri(request) 67 | .replacePath(null) 68 | .build() 69 | .toUriString(); 70 | userService.sendPasswordResetEmail(baseUrl, userDTO); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /techbuzz/src/main/java/com/sivalabs/techbuzz/users/domain/models/User.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.domain.models; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import jakarta.validation.constraints.Size; 6 | import java.io.Serializable; 7 | import java.util.Arrays; 8 | 9 | public class User implements Serializable { 10 | private Long id; 11 | 12 | @NotEmpty 13 | private String name; 14 | 15 | @NotEmpty 16 | @Email(message = "Invalid email") 17 | private String email; 18 | 19 | @NotEmpty 20 | @Size(min = 4) 21 | private String password; 22 | 23 | private RoleEnum role; 24 | 25 | private boolean verified; 26 | 27 | private String verificationToken; 28 | private String passwordResetToken; 29 | 30 | public User() {} 31 | 32 | public User(Long id) { 33 | this.id = id; 34 | } 35 | 36 | public User( 37 | Long id, 38 | String name, 39 | String email, 40 | String password, 41 | RoleEnum role, 42 | boolean verified, 43 | String verificationToken, 44 | String passwordResetToken) { 45 | this.id = id; 46 | this.name = name; 47 | this.email = email; 48 | this.password = password; 49 | this.role = role; 50 | this.verified = verified; 51 | this.verificationToken = verificationToken; 52 | this.passwordResetToken = passwordResetToken; 53 | } 54 | 55 | public boolean isAdminOrModerator() { 56 | return hasAnyRole(RoleEnum.ROLE_ADMIN, RoleEnum.ROLE_MODERATOR); 57 | } 58 | 59 | public boolean hasAnyRole(RoleEnum... roles) { 60 | return Arrays.asList(roles).contains(this.getRole()); 61 | } 62 | 63 | public void setId(Long id) { 64 | this.id = id; 65 | } 66 | 67 | public void setName(String name) { 68 | this.name = name; 69 | } 70 | 71 | public void setEmail(String email) { 72 | this.email = email; 73 | } 74 | 75 | public void setPassword(String password) { 76 | this.password = password; 77 | } 78 | 79 | public void setRole(RoleEnum role) { 80 | this.role = role; 81 | } 82 | 83 | public void setVerified(Boolean verified) { 84 | this.verified = verified; 85 | } 86 | 87 | public void setVerificationToken(String verificationToken) { 88 | this.verificationToken = verificationToken; 89 | } 90 | 91 | public void setPasswordResetToken(String passwordResetToken) { 92 | this.passwordResetToken = passwordResetToken; 93 | } 94 | 95 | public Long getId() { 96 | return this.id; 97 | } 98 | 99 | public String getName() { 100 | return this.name; 101 | } 102 | 103 | public String getEmail() { 104 | return this.email; 105 | } 106 | 107 | public String getPassword() { 108 | return this.password; 109 | } 110 | 111 | public RoleEnum getRole() { 112 | return this.role; 113 | } 114 | 115 | public Boolean isVerified() { 116 | return this.verified; 117 | } 118 | 119 | public String getVerificationToken() { 120 | return this.verificationToken; 121 | } 122 | 123 | public String getPasswordResetToken() { 124 | return passwordResetToken; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /techbuzz/src/main/resources/templates/fragments/post.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
8 |
9 |
10 |

11 | 12 |

13 |

14 |

15 | Posted By: Name 16 | Date: Date 17 | Category: Category 18 |
19 |
20 | Post content 21 |
22 |

23 | 24 | 25 | Edit 26 | 27 | 28 | 32 | 33 | 38 | 39 | 44 |

45 |

46 | 47 | 51 | 52 | 53 | 57 | 58 |

59 | 60 | 61 |
62 |
63 |
64 | 65 | -------------------------------------------------------------------------------- /techbuzz/src/test/java/com/sivalabs/techbuzz/users/web/controllers/RegistrationControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.techbuzz.users.web.controllers; 2 | 3 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 5 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash; 7 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; 8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 9 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 10 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 11 | 12 | import com.sivalabs.techbuzz.common.AbstractIntegrationTest; 13 | import org.junit.jupiter.api.Test; 14 | 15 | class RegistrationControllerTests extends AbstractIntegrationTest { 16 | 17 | @Test 18 | void shouldShowRegistrationFormPage() throws Exception { 19 | mockMvc.perform(get("/registration")) 20 | .andExpect(status().isOk()) 21 | .andExpect(view().name("users/registration")) 22 | .andExpect(model().attributeExists("user")); 23 | } 24 | 25 | @Test 26 | void shouldRegisterSuccessfully() throws Exception { 27 | mockMvc.perform(post("/registration") 28 | .with(csrf()) 29 | .param("name", "dummy") 30 | .param("email", "dummy@mail.com") 31 | .param("password", "admin1234")) 32 | .andExpect(status().is3xxRedirection()) 33 | .andExpect(flash().attribute("message", "Registration is successful")) 34 | .andExpect(header().string("Location", "/registrationStatus")); 35 | } 36 | 37 | @Test 38 | void shouldRedisplayRegistrationFormPageWhenSubmittedInvalidData() throws Exception { 39 | mockMvc.perform(post("/registration") 40 | .with(csrf()) 41 | .param("name", "") 42 | .param("email", "") 43 | .param("password", "")) 44 | .andExpect(model().hasErrors()) 45 | .andExpect(model().attributeHasFieldErrors("user", "name", "email", "password")) 46 | .andExpect(model().attributeHasFieldErrorCode("user", "name", "NotBlank")) 47 | .andExpect(model().attributeHasFieldErrorCode("user", "email", "NotBlank")) 48 | .andExpect(model().attributeHasFieldErrorCode("user", "password", "NotBlank")) 49 | .andExpect(view().name("users/registration")); 50 | } 51 | 52 | @Test 53 | void shouldRedisplayRegistrationFormPageWhenEmailAlreadyExists() throws Exception { 54 | mockMvc.perform(post("/registration") 55 | .with(csrf()) 56 | .param("name", "Siva") 57 | .param("email", ADMIN_EMAIL) 58 | .param("password", "siva")) 59 | .andExpect(model().hasErrors()) 60 | .andExpect(model().attributeHasFieldErrors("user", "email")) 61 | .andExpect(model().attributeHasFieldErrorCode("user", "email", "email.exists")) 62 | .andExpect(view().name("users/registration")); 63 | } 64 | } 65 | --------------------------------------------------------------------------------