diff --git a/.env.example b/.env.example index 1da8948..a2f1e07 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,27 @@ -APP_PORT_BACKEND=8888 -MYSQL_DB_HOST=db -MYSQL_DB_PORT=3311 -MYSQL_DB_UNAME=root -MYSQL_DB_PASSWD=root \ No newline at end of file +# ============================================================================== +# Moo: Twitter Clone - Developer Environment Configuration +# ============================================================================== + +# --- DATABASE CONFIGURATION --- +# Internal host used by Docker; use 'localhost' if running app natively +DB_HOST=db +DB_PORT=3306 +DB_PORT_HOST=3306 +DB_NAME=twitter_clone +DB_USER=moouser +DB_PASS=moopass +DB_ROOT_PASS=rootpass + +# --- SECURITY CONFIGURATION --- +# Minimum 64 characters for high-performance hashing +JWT_SECRET=9a4f4342453527245a462d4a614e645267556b58703273357638792f423f4528 +JWT_EXPIRATION=3600000 + +# --- APP PORT CONFIGURATION --- +# Port exposed to the host machine (avoids 8080 collision) +HOST_PORT=8082 +ADMINER_PORT=8083 + +# --- SPRING BOOT CONFIGURATION --- +SPRING_PROFILES_ACTIVE=prod +LOGGING_LEVEL_XYZ_SUBHO=INFO diff --git a/.gitignore b/.gitignore index 6a1d604..0bbfac1 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ build/ .env GEMINI.md UPGRADE_PROPOSAL.md +ROBUST_EVOLUTION_PLAN.md diff --git a/.run/Moo_API.run.xml b/.run/Moo_API.run.xml new file mode 100644 index 0000000..9024d94 --- /dev/null +++ b/.run/Moo_API.run.xml @@ -0,0 +1,38 @@ + + + + + + diff --git a/.run/Moo_Dev_Dependencies.run.xml b/.run/Moo_Dev_Dependencies.run.xml new file mode 100644 index 0000000..4f2abf7 --- /dev/null +++ b/.run/Moo_Dev_Dependencies.run.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/.run/Moo_Full_Stack.run.xml b/.run/Moo_Full_Stack.run.xml new file mode 100644 index 0000000..4073e7d --- /dev/null +++ b/.run/Moo_Full_Stack.run.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/README.md b/README.md index 1f38929..f0b0433 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![PR Checker](https://github.com/scaleracademy/twitter-backend-java/actions/workflows/pr-checker.yml/badge.svg)](https://github.com/scaleracademy/twitter-backend-java/actions/workflows/pr-checker.yml) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) -An robust, high-performance Twitter clone backend built with the latest +A robust, high-performance Twitter clone backend built with the latest industry standards. ## Modern Tech Stack @@ -28,38 +28,30 @@ industry standards. ### Quick Start -1. **Clone the Repository:** +Choose the setup that fits your workflow: - ```bash - git clone https://github.com/scaleracademy/twitter-backend-java - cd twitter-backend-java - ``` +#### Option A: Zero-Installation (Full Stack) -2. **Environment Setup:** +Ideal for testing or a quick look. Runs everything in Docker. - ```bash - cp .env.example .env - # Edit .env with your local MySQL credentials if needed - ``` +1. `cp .env.example .env` +2. `docker compose up -d` +3. API: `http://localhost:8082` | DB Admin: `http://localhost:8083` -3. **Run with Maven:** +#### Option B: Native Development (Dependencies Only) - ```bash - ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev - ``` +Ideal for coding. Runs DB in Docker, App in your IDE/CLI. -4. **Run with Docker:** - - ```bash - docker-compose up -d - ``` - -The API will be available at `http://localhost:8080`. +1. `cp .env.example .env` +2. `docker compose -f docker-compose.dev.yml up -d` +3. Run the **"Moo API"** configuration in IntelliJ IDEA, or use: + `./mvnw spring-boot:run -Dspring-boot.run.profiles=dev` +4. API: `http://localhost:8080` (Standard) | DB Admin: `http://localhost:8083` ## API Documentation Access the Interactive Swagger UI at: -👉 `http://localhost:8080/swagger-ui/index.html` +👉 `http://localhost:8082/swagger-ui/index.html` ### Key Endpoints diff --git a/docker-compose.base.yml b/docker-compose.base.yml new file mode 100644 index 0000000..dd75d29 --- /dev/null +++ b/docker-compose.base.yml @@ -0,0 +1,62 @@ +# +# Twitter Backend - Moo: Twitter Clone Application Backend by Scaler +# Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +# +# Twitter Backend - Moo: Base Dependencies (Shared) +# + +services: + db: + image: mysql:8.4 + container_name: moo-db + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_DATABASE: ${DB_NAME:-twitter_clone} + MYSQL_USER: ${DB_USER:-moouser} + MYSQL_PASSWORD: ${DB_PASS:-moopass} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootpass} + ports: + - "${DB_PORT_HOST:-3306}:3306" + volumes: + - moo-data:/var/lib/mysql + networks: + - moo-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"] + timeout: 5s + retries: 10 + restart: always + + adminer: + image: adminer:latest + container_name: moo-adminer + ports: + - "${ADMINER_PORT:-8083}:8080" + depends_on: + - db + networks: + - moo-network + restart: always + +networks: + moo-network: + driver: bridge + +volumes: + moo-data: + driver: local diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..a231346 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,24 @@ +# +# Twitter Backend - Moo: Twitter Clone Application Backend by Scaler +# Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +# +# Twitter Backend - Moo: Developer Dependencies Stack (DB + Adminer) +# + +include: + - docker-compose.base.yml diff --git a/docker-compose.yml b/docker-compose.yml index d0edf59..be8c073 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,40 +16,35 @@ # along with this program. If not, see . # -version: "3" - +# +# Twitter Backend - Moo: Full Stack (API + Dependencies) +# + +include: + - docker-compose.base.yml + services: - - db: - image: mysql:8 - container_name: db - volumes: - - db_data:/var/lib/mysql - restart: always - hostname: db - ports: - - "${MYSQL_DB_PORT}:3306" - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: twitter - MYSQL_USER: twitter - MYSQL_PASSWORD: twitter - - twitter-backend: - depends_on: - - db - container_name: twitter-backend + app: build: context: . - dockerfile: Dockerfile + args: + JAR_FILE: target/*.jar + container_name: moo-api ports: - - "${APP_PORT_BACKEND}:8080" - restart: always + - "${HOST_PORT:-8082}:8080" environment: - MYSQL_DB_HOST: ${MYSQL_DB_HOST} - MYSQL_DB_PORT: ${MYSQL_DB_PORT} - MYSQL_DB_UNAME: ${MYSQL_DB_UNAME} - MYSQL_DB_PASSWD: ${MYSQL_DB_PASSWD} - -volumes: - db_data: {} + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod} + - MYSQL_DB_HOST=${DB_HOST:-db} + - MYSQL_DB_PORT=${DB_PORT:-3306} + - MYSQL_DB_UNAME=${DB_USER:-moouser} + - MYSQL_DB_PASSWD=${DB_PASS:-moopass} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRATION=${JWT_EXPIRATION} + depends_on: + db: + condition: service_healthy + restart: always + deploy: + resources: + limits: + memory: 512M diff --git a/pom.xml b/pom.xml index bc53f3f..bc4ba34 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.0 + 4.0.5 @@ -81,6 +81,17 @@ spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-aop + 3.4.3 + + + + io.micrometer + micrometer-registry-prometheus + + org.springframework.boot spring-boot-starter-data-jpa @@ -139,9 +150,9 @@ - org.projectlombok - lombok - true + org.mapstruct + mapstruct + 1.6.0.Beta1 @@ -177,6 +188,20 @@ spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.mapstruct + mapstruct-processor + 1.6.0.Beta1 + + + + + com.diffplug.spotless spotless-maven-plugin diff --git a/src/main/java/xyz/subho/clone/twitter/config/JpaConfig.java b/src/main/java/xyz/subho/clone/twitter/config/JpaConfig.java new file mode 100644 index 0000000..9e2bfd1 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/config/JpaConfig.java @@ -0,0 +1,26 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig {} diff --git a/src/main/java/xyz/subho/clone/twitter/config/MicrometerConfig.java b/src/main/java/xyz/subho/clone/twitter/config/MicrometerConfig.java new file mode 100644 index 0000000..82bd2e2 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/config/MicrometerConfig.java @@ -0,0 +1,41 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.config; + +import io.micrometer.core.aop.CountedAspect; +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +@Configuration +@EnableAspectJAutoProxy +public class MicrometerConfig { + + @Bean + public TimedAspect timedAspect(MeterRegistry registry) { + return new TimedAspect(registry); + } + + @Bean + public CountedAspect countedAspect(MeterRegistry registry) { + return new CountedAspect(registry); + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/constant/ApiVersion.java b/src/main/java/xyz/subho/clone/twitter/constant/ApiVersion.java new file mode 100644 index 0000000..236ddd2 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/constant/ApiVersion.java @@ -0,0 +1,29 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.constant; + +/** ApiVersion - Holds the global API versioning constants. */ +public class ApiVersion { + + public static final String V1 = "/v1"; + + private ApiVersion() { + // Prevent instantiation + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/constant/AuthV1Constants.java b/src/main/java/xyz/subho/clone/twitter/constant/AuthV1Constants.java new file mode 100644 index 0000000..7749887 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/constant/AuthV1Constants.java @@ -0,0 +1,32 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.constant; + +/** AuthV1Constants - API endpoint constants for Authentication V1 Controller. */ +public class AuthV1Constants { + + public static final String BASE_PATH = ApiVersion.V1; + public static final String AUTHENTICATE = "/authenticate"; + public static final String REFRESH = "/refresh"; + public static final String LOGOUT = "/logout"; + + private AuthV1Constants() { + // Prevent instantiation + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/constant/HashtagV1Constants.java b/src/main/java/xyz/subho/clone/twitter/constant/HashtagV1Constants.java new file mode 100644 index 0000000..2af98ae --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/constant/HashtagV1Constants.java @@ -0,0 +1,30 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.constant; + +/** HashtagV1Constants - API endpoint constants for Hashtag V1 Controller. */ +public class HashtagV1Constants { + + public static final String BASE_PATH = ApiVersion.V1 + "/hashtags"; + public static final String TAG_POSTS = "/{tag}/posts"; + + private HashtagV1Constants() { + // Prevent instantiation + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/constant/PostV1Constants.java b/src/main/java/xyz/subho/clone/twitter/constant/PostV1Constants.java new file mode 100644 index 0000000..7f88ada --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/constant/PostV1Constants.java @@ -0,0 +1,32 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.constant; + +/** PostV1Constants - API endpoint constants for Post V1 Controller. */ +public class PostV1Constants { + + public static final String BASE_PATH = ApiVersion.V1 + "/posts"; + public static final String POST_ID = "/{postId}"; + public static final String LIKE = "/{postId}/like"; + public static final String REPLIES = "/{postId}/replies"; + + private PostV1Constants() { + // Prevent instantiation + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/constant/UserV1Constants.java b/src/main/java/xyz/subho/clone/twitter/constant/UserV1Constants.java new file mode 100644 index 0000000..5fa1694 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/constant/UserV1Constants.java @@ -0,0 +1,33 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.constant; + +/** UserV1Constants - API endpoint constants for User V1 Controller. */ +public class UserV1Constants { + + public static final String BASE_PATH = ApiVersion.V1 + "/users"; + public static final String USER_ID_OR_NAME = "/{userNameOrUserId}"; + public static final String FOLLOW = "/{userId}/follow"; + public static final String FOLLOWERS = "/{userId}/followers"; + public static final String FOLLOWINGS = "/{userId}/followings"; + + private UserV1Constants() { + // Prevent instantiation + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/controller/AuthenticationController.java b/src/main/java/xyz/subho/clone/twitter/controller/AuthenticationController.java index b97dcb9..2d01790 100644 --- a/src/main/java/xyz/subho/clone/twitter/controller/AuthenticationController.java +++ b/src/main/java/xyz/subho/clone/twitter/controller/AuthenticationController.java @@ -18,46 +18,95 @@ package xyz.subho.clone.twitter.controller; -import org.springframework.beans.factory.annotation.Autowired; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import jakarta.validation.Valid; +import java.security.Principal; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import xyz.subho.clone.twitter.constant.AuthV1Constants; import xyz.subho.clone.twitter.exception.BadRequestException; import xyz.subho.clone.twitter.model.AuthenticationRequest; import xyz.subho.clone.twitter.model.AuthenticationResponse; +import xyz.subho.clone.twitter.model.TokenRefreshRequest; import xyz.subho.clone.twitter.security.JwtUtil; import xyz.subho.clone.twitter.security.UserDetailsServiceImpl; +import xyz.subho.clone.twitter.service.RefreshTokenService; +import xyz.subho.clone.twitter.service.UserService; @RestController +@RequestMapping(AuthV1Constants.BASE_PATH) +@Timed(value = "moo.auth.timer", description = "Time taken to process auth requests") public class AuthenticationController { - @Autowired private AuthenticationManager authenticationManager; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtTokenUtil; + private final UserDetailsServiceImpl userDetailsService; + private final RefreshTokenService refreshTokenService; + private final UserService userService; - @Autowired private JwtUtil jwtTokenUtil; - - @Autowired private UserDetailsServiceImpl userDetailsService; + public AuthenticationController( + AuthenticationManager authenticationManager, + JwtUtil jwtTokenUtil, + UserDetailsServiceImpl userDetailsService, + RefreshTokenService refreshTokenService, + UserService userService) { + this.authenticationManager = authenticationManager; + this.jwtTokenUtil = jwtTokenUtil; + this.userDetailsService = userDetailsService; + this.refreshTokenService = refreshTokenService; + this.userService = userService; + } - @PostMapping("/authenticate") - public ResponseEntity createAuthenticationToken( + @PostMapping(AuthV1Constants.AUTHENTICATE) + @Counted(value = "moo.auth.login", description = "Number of user logins") + public ResponseEntity createAuthenticationToken( @RequestBody AuthenticationRequest authenticationRequest) { try { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( - authenticationRequest.getUsername(), authenticationRequest.getPassword())); + authenticationRequest.username(), authenticationRequest.password())); } catch (BadCredentialsException e) { throw new BadRequestException("Incorrect username or password", e); } - final var userDetails = - userDetailsService.loadUserByUsername(authenticationRequest.getUsername()); + final var userDetails = userDetailsService.loadUserByUsername(authenticationRequest.username()); final String jwt = jwtTokenUtil.generateToken(userDetails); + var user = userService.getUserByUserName(authenticationRequest.username()); + var refreshToken = refreshTokenService.createRefreshToken(user.id()); + + return ResponseEntity.ok(new AuthenticationResponse(jwt, refreshToken.getToken())); + } + + @PostMapping(AuthV1Constants.REFRESH) + public ResponseEntity refreshToken( + @Valid @RequestBody TokenRefreshRequest request) { + return refreshTokenService + .findByToken(request.refreshToken()) + .map(refreshTokenService::verifyExpiration) + .map( + token -> { + var user = token.getUsers(); + String jwt = + jwtTokenUtil.generateToken( + userDetailsService.loadUserByUsername(user.getUsername())); + return ResponseEntity.ok(new AuthenticationResponse(jwt, token.getToken())); + }) + .orElseThrow(() -> new BadRequestException("Refresh token is not in database!")); + } - return ResponseEntity.ok(new AuthenticationResponse(jwt)); + @PostMapping(AuthV1Constants.LOGOUT) + public ResponseEntity logoutUser(Principal principal) { + var user = userService.getUserByUserName(principal.getName()); + refreshTokenService.deleteByUserId(user.id()); + return ResponseEntity.ok("Log out successful!"); } } diff --git a/src/main/java/xyz/subho/clone/twitter/controller/HashtagController.java b/src/main/java/xyz/subho/clone/twitter/controller/HashtagController.java index 432b4b1..0593e7f 100644 --- a/src/main/java/xyz/subho/clone/twitter/controller/HashtagController.java +++ b/src/main/java/xyz/subho/clone/twitter/controller/HashtagController.java @@ -18,27 +18,38 @@ package xyz.subho.clone.twitter.controller; -import java.util.List; -import org.springframework.beans.factory.annotation.Autowired; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import xyz.subho.clone.twitter.constant.HashtagV1Constants; import xyz.subho.clone.twitter.model.HashtagModel; import xyz.subho.clone.twitter.model.PostModel; import xyz.subho.clone.twitter.service.HashtagService; @RestController +@RequestMapping(HashtagV1Constants.BASE_PATH) +@Timed(value = "moo.hashtags.timer", description = "Time taken to process hashtag requests") public class HashtagController { - @Autowired private HashtagService hashtagService; + private final HashtagService hashtagService; - @GetMapping("/hashtags") - public List getAllHashtags() { - return hashtagService.getHashtags(); + public HashtagController(HashtagService hashtagService) { + this.hashtagService = hashtagService; } - @GetMapping("/hashtag/{tag}/posts") - public List getPosts(@PathVariable("tag") String tag) { - return hashtagService.getPosts(tag); + @GetMapping + @Counted(value = "moo.hashtags.viewed", description = "Number of times hashtags were viewed") + public Page getAllHashtags(Pageable pageable) { + return hashtagService.getHashtags(pageable); + } + + @GetMapping(HashtagV1Constants.TAG_POSTS) + public Page getPosts(@PathVariable("tag") String tag, Pageable pageable) { + return hashtagService.getPosts(tag, pageable); } } diff --git a/src/main/java/xyz/subho/clone/twitter/controller/PostController.java b/src/main/java/xyz/subho/clone/twitter/controller/PostController.java index 14fc01c..96a1fdf 100644 --- a/src/main/java/xyz/subho/clone/twitter/controller/PostController.java +++ b/src/main/java/xyz/subho/clone/twitter/controller/PostController.java @@ -18,10 +18,13 @@ package xyz.subho.clone.twitter.controller; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import jakarta.validation.Valid; import java.security.Principal; import java.util.UUID; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -34,18 +37,25 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import xyz.subho.clone.twitter.constant.PostV1Constants; import xyz.subho.clone.twitter.model.PostModel; import xyz.subho.clone.twitter.service.PostService; import xyz.subho.clone.twitter.service.UserService; @RestController -@RequestMapping("/posts") -@Slf4j +@RequestMapping(PostV1Constants.BASE_PATH) +@Timed(value = "moo.posts.timer", description = "Time taken to process post requests") public class PostController { - @Autowired private PostService postService; + private static final Logger log = LoggerFactory.getLogger(PostController.class); - @Autowired private UserService userService; + private final PostService postService; + private final UserService userService; + + public PostController(PostService postService, UserService userService) { + this.postService = postService; + this.userService = userService; + } @GetMapping public ResponseEntity> getAllPosts(Pageable pageable) { @@ -53,41 +63,76 @@ public ResponseEntity> getAllPosts(Pageable pageable) { return new ResponseEntity<>(posts, HttpStatus.OK); } - @GetMapping("/{postId}") + @GetMapping(PostV1Constants.POST_ID) public ResponseEntity getPost(@PathVariable("postId") UUID postId) { PostModel post = postService.getPost(postId); return new ResponseEntity<>(post, HttpStatus.OK); } @PostMapping - public ResponseEntity addPost(@RequestBody PostModel postModel, Principal principal) { + @Counted(value = "moo.posts.created", description = "Number of posts created") + public ResponseEntity addPost( + @Valid @RequestBody PostModel postModel, Principal principal) { var user = userService.getUserByUserName(principal.getName()); - postModel.setUserId(user.getId()); - PostModel post = postService.addPost(postModel); + if (user == null) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + + // Create new record instance with the authenticated userId + PostModel enrichedPost = + new PostModel( + postModel.id(), + postModel.text(), + user.id(), + postModel.images(), + postModel.likeCount(), + postModel.repostCount(), + postModel.originalPostId(), + postModel.replyToId(), + postModel.timestamp(), + postModel.hashtags(), + postModel.mentions()); + + PostModel post = postService.addPost(enrichedPost); return new ResponseEntity<>(post, HttpStatus.OK); } - @DeleteMapping("/{postId}") + @DeleteMapping(PostV1Constants.POST_ID) public ResponseEntity deletePost( @PathVariable("postId") UUID postId, Principal principal) { var user = userService.getUserByUserName(principal.getName()); - postService.deletePost(postId, user.getId()); + if (user != null) { + postService.deletePost(postId, user.id()); + } return new ResponseEntity<>(HttpStatus.OK); } - @PutMapping("/{postId}/like") + @PutMapping(PostV1Constants.LIKE) + @Counted(value = "moo.posts.liked", description = "Number of posts liked") public ResponseEntity likePost(@PathVariable("postId") UUID postId, Principal principal) { var user = userService.getUserByUserName(principal.getName()); - return new ResponseEntity<>(postService.addLike(postId, user.getId()), HttpStatus.CREATED); + if (user == null) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + return new ResponseEntity<>(postService.addLike(postId, user.id()), HttpStatus.CREATED); } - @DeleteMapping("/{postId}/like") + @DeleteMapping(PostV1Constants.LIKE) public ResponseEntity removeLikePost( @PathVariable("postId") UUID postId, Principal principal) { var user = userService.getUserByUserName(principal.getName()); - return new ResponseEntity<>(postService.removeLike(postId, user.getId()), HttpStatus.OK); + if (user == null) { + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + return new ResponseEntity<>(postService.removeLike(postId, user.id()), HttpStatus.OK); + } + + @GetMapping(PostV1Constants.REPLIES) + public ResponseEntity> getReplies( + @PathVariable("postId") UUID postId, Pageable pageable) { + return new ResponseEntity<>(postService.getReplies(postId, pageable), HttpStatus.OK); } } diff --git a/src/main/java/xyz/subho/clone/twitter/controller/UserController.java b/src/main/java/xyz/subho/clone/twitter/controller/UserController.java index 142df81..6e1b966 100644 --- a/src/main/java/xyz/subho/clone/twitter/controller/UserController.java +++ b/src/main/java/xyz/subho/clone/twitter/controller/UserController.java @@ -18,11 +18,13 @@ package xyz.subho.clone.twitter.controller; +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; import jakarta.validation.Valid; import java.security.Principal; import java.util.UUID; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -36,20 +38,27 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import xyz.subho.clone.twitter.constant.UserV1Constants; import xyz.subho.clone.twitter.model.UserModel; import xyz.subho.clone.twitter.service.UserService; import xyz.subho.clone.twitter.utility.Utility; @RestController -@RequestMapping("/users") -@Slf4j +@RequestMapping(UserV1Constants.BASE_PATH) +@Timed(value = "moo.users.timer", description = "Time taken to process user requests") public class UserController { - @Autowired private UserService userService; + private static final Logger log = LoggerFactory.getLogger(UserController.class); - @Autowired private Utility utility; + private final UserService userService; + private final Utility utility; - @GetMapping("/{userNameOrUserId}") + public UserController(UserService userService, Utility utility) { + this.userService = userService; + this.utility = utility; + } + + @GetMapping(UserV1Constants.USER_ID_OR_NAME) public ResponseEntity getUserByUserIdOrUserName( @PathVariable("userNameOrUserId") String userNameOrUserId) { @@ -69,37 +78,44 @@ public ResponseEntity getUserByUserIdOrUserName( } @PostMapping + @Counted(value = "moo.users.signup", description = "Number of user signups") public ResponseEntity createUser(@Valid @RequestBody UserModel userResponse) { var user = userService.addUser(userResponse); return new ResponseEntity<>(user, HttpStatus.CREATED); } @PatchMapping + @Timed(value = "moo.users.update", description = "Time taken to update user profile") public UserModel updateUser(@Valid @RequestBody UserModel userResponse, Principal principal) { return userService.editUser(userResponse); } - @PutMapping("/{userId}/follow") + @PutMapping(UserV1Constants.FOLLOW) + @Counted(value = "moo.users.follow", description = "Number of follow actions") public ResponseEntity addFollower(@PathVariable UUID userId, Principal principal) { var follower = userService.getUserByUserName(principal.getName()); - userService.addFollower(follower.getId(), userId); + if (follower != null) { + userService.addFollower(follower.id(), userId); + } return new ResponseEntity<>(HttpStatus.CREATED); } - @DeleteMapping("/{userId}/follow") + @DeleteMapping(UserV1Constants.FOLLOW) public ResponseEntity removeFollower( @PathVariable("userId") UUID userId, Principal principal) { var follower = userService.getUserByUserName(principal.getName()); - userService.removeFollower(follower.getId(), userId); + if (follower != null) { + userService.removeFollower(follower.id(), userId); + } return new ResponseEntity<>(HttpStatus.CREATED); } - @GetMapping("/{userId}/followers") + @GetMapping(UserV1Constants.FOLLOWERS) public Page getFollowers(@PathVariable("userId") UUID userId, Pageable pageable) { return userService.getFollowers(userId, pageable); } - @GetMapping("/{userId}/followings") + @GetMapping(UserV1Constants.FOLLOWINGS) public Page getFollowings(@PathVariable("userId") UUID userId, Pageable pageable) { return userService.getFollowings(userId, pageable); } diff --git a/src/main/java/xyz/subho/clone/twitter/entity/Auditable.java b/src/main/java/xyz/subho/clone/twitter/entity/Auditable.java new file mode 100644 index 0000000..8b4a58e --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/entity/Auditable.java @@ -0,0 +1,60 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.util.Date; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class Auditable { + + @CreatedDate + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "created_at", nullable = false, updatable = false) + private Date createdAt; + + @LastModifiedDate + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "updated_at", nullable = false) + private Date updatedAt; + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + public Date getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Date updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/entity/Follow.java b/src/main/java/xyz/subho/clone/twitter/entity/Follow.java new file mode 100644 index 0000000..cb19d7b --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/entity/Follow.java @@ -0,0 +1,95 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table( + name = "follows", + uniqueConstraints = {@UniqueConstraint(columnNames = {"follower_id", "following_id"})}) +public class Follow extends Auditable { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(columnDefinition = "BINARY(16)") + private UUID id; + + @ManyToOne + @JoinColumn(name = "follower_id", nullable = false) + private Users follower; + + @ManyToOne + @JoinColumn(name = "following_id", nullable = false) + private Users following; + + public Follow() {} + + public Follow(Users follower, Users following) { + this.follower = follower; + this.following = following; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Users getFollower() { + return follower; + } + + public void setFollower(Users follower) { + this.follower = follower; + } + + public Users getFollowing() { + return following; + } + + public void setFollowing(Users following) { + this.following = following; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Follow follow = (Follow) o; + return Objects.equals(id, follow.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/entity/HashtagPosts.java b/src/main/java/xyz/subho/clone/twitter/entity/HashtagPosts.java index 6ffa08d..2944c90 100644 --- a/src/main/java/xyz/subho/clone/twitter/entity/HashtagPosts.java +++ b/src/main/java/xyz/subho/clone/twitter/entity/HashtagPosts.java @@ -26,23 +26,19 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.util.Date; +import java.util.Objects; import java.util.UUID; -import lombok.Data; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; @Entity @Table(name = "hashtag_posts") -@Data -public class HashtagPosts { +public class HashtagPosts extends Auditable { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(columnDefinition = "BINARY(16)") private UUID id; - @ManyToOne(targetEntity = Hashtags.class) + @ManyToOne @JoinColumn( name = "hashtags_id", columnDefinition = "BINARY(16)", @@ -50,7 +46,7 @@ public class HashtagPosts { nullable = false) private Hashtags hashtags; - @ManyToOne(targetEntity = Posts.class) + @ManyToOne @JoinColumn( name = "posts_id", columnDefinition = "BINARY(16)", @@ -58,7 +54,45 @@ public class HashtagPosts { nullable = false) private Posts posts; - @CreationTimestamp private Date createdAt; + public UUID getId() { + return id; + } - @UpdateTimestamp private Date updatedAt; + public void setId(UUID id) { + this.id = id; + } + + public Hashtags getHashtags() { + return hashtags; + } + + public void setHashtags(Hashtags hashtags) { + this.hashtags = hashtags; + } + + public Posts getPosts() { + return posts; + } + + public void setPosts(Posts posts) { + this.posts = posts; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + HashtagPosts that = (HashtagPosts) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "HashtagPosts{" + "id=" + id + '}'; + } } diff --git a/src/main/java/xyz/subho/clone/twitter/entity/Hashtags.java b/src/main/java/xyz/subho/clone/twitter/entity/Hashtags.java index 8922e2a..d5ebd08 100644 --- a/src/main/java/xyz/subho/clone/twitter/entity/Hashtags.java +++ b/src/main/java/xyz/subho/clone/twitter/entity/Hashtags.java @@ -30,36 +30,78 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.ArrayList; -import java.util.Date; import java.util.List; +import java.util.Objects; import java.util.UUID; -import lombok.Data; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; @Entity @Table( name = "hashtags", indexes = {@Index(columnList = "tag")}) -@Data -public class Hashtags { +public class Hashtags extends Auditable { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(columnDefinition = "BINARY(16)") private UUID id; - @Column(unique = true, nullable = false) + @Column(unique = true, nullable = false, length = 50) private String tag; - @Column(name = "recent_post_count", columnDefinition = "BIGINT(20) default '1'", nullable = false) - private Long recentPostCount = 1L; + @Column(name = "post_count", columnDefinition = "BIGINT(20) default '0'", nullable = false) + private long recentPostCount = 0L; @OneToMany(mappedBy = "hashtags", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @JsonIgnore private List hashtagPosts = new ArrayList<>(); - @CreationTimestamp private Date createdAt; + public UUID getId() { + return id; + } - @UpdateTimestamp private Date updatedAt; + public void setId(UUID id) { + this.id = id; + } + + public String getTag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + + public long getRecentPostCount() { + return recentPostCount; + } + + public void setRecentPostCount(long recentPostCount) { + this.recentPostCount = recentPostCount; + } + + public List getHashtagPosts() { + return hashtagPosts; + } + + public void setHashtagPosts(List hashtagPosts) { + this.hashtagPosts = hashtagPosts; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Hashtags hashtags = (Hashtags) o; + return Objects.equals(id, hashtags.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Hashtags{" + "id=" + id + ", tag='" + tag + '\'' + '}'; + } } diff --git a/src/main/java/xyz/subho/clone/twitter/entity/Likes.java b/src/main/java/xyz/subho/clone/twitter/entity/Likes.java index 415c520..fe9805e 100644 --- a/src/main/java/xyz/subho/clone/twitter/entity/Likes.java +++ b/src/main/java/xyz/subho/clone/twitter/entity/Likes.java @@ -26,39 +26,73 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import java.util.Date; +import java.util.Objects; import java.util.UUID; -import lombok.Data; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; @Entity @Table(name = "likes") -@Data -public class Likes { +public class Likes extends Auditable { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(columnDefinition = "BINARY(16)") private UUID id; - @ManyToOne(targetEntity = Posts.class) + @ManyToOne @JoinColumn( - name = "posts_id", + name = "users_id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) - private Posts posts; + private Users users; - @ManyToOne(targetEntity = Users.class) + @ManyToOne @JoinColumn( - name = "users_id", + name = "posts_id", columnDefinition = "BINARY(16)", updatable = false, nullable = false) - private Users users; + private Posts posts; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Users getUsers() { + return users; + } + + public void setUsers(Users users) { + this.users = users; + } + + public Posts getPosts() { + return posts; + } + + public void setPosts(Posts posts) { + this.posts = posts; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Likes likes = (Likes) o; + return Objects.equals(id, likes.id); + } - @CreationTimestamp private Date createdAt; + @Override + public int hashCode() { + return Objects.hashCode(id); + } - @UpdateTimestamp private Date updatedAt; + @Override + public String toString() { + return "Likes{" + "id=" + id + '}'; + } } diff --git a/src/main/java/xyz/subho/clone/twitter/entity/Posts.java b/src/main/java/xyz/subho/clone/twitter/entity/Posts.java index 228b92d..027291a 100644 --- a/src/main/java/xyz/subho/clone/twitter/entity/Posts.java +++ b/src/main/java/xyz/subho/clone/twitter/entity/Posts.java @@ -36,16 +36,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; -import lombok.Data; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; import org.springframework.data.annotation.CreatedBy; @Entity @Table(name = "posts") -@Data -public class Posts { +public class Posts extends Auditable { @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -78,10 +75,6 @@ public class Posts { @Column(name = "reply_to_id") private UUID replyToId; - @CreationTimestamp private Date timestamp; - - @UpdateTimestamp private Date updatedAt; - @ElementCollection private Map hashtags = new HashMap<>(); @ElementCollection private Map mentions = new HashMap<>(); @@ -109,4 +102,118 @@ public long incrementRepostCount() { public long decrementRepostCount() { return (repostCount < 1) ? 0 : --repostCount; } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public Users getUsers() { + return users; + } + + public void setUsers(Users users) { + this.users = users; + } + + public Map getImages() { + return images; + } + + public void setImages(Map images) { + this.images = images; + } + + public Long getLikeCount() { + return likeCount; + } + + public void setLikeCount(Long likeCount) { + this.likeCount = likeCount; + } + + public Long getRepostCount() { + return repostCount; + } + + public void setRepostCount(Long repostCount) { + this.repostCount = repostCount; + } + + public UUID getOriginalPostId() { + return originalPostId; + } + + public void setOriginalPostId(UUID originalPostId) { + this.originalPostId = originalPostId; + } + + public UUID getReplyToId() { + return replyToId; + } + + public void setReplyToId(UUID replyToId) { + this.replyToId = replyToId; + } + + public Map getHashtags() { + return hashtags; + } + + public void setHashtags(Map hashtags) { + this.hashtags = hashtags; + } + + public Map getMentions() { + return mentions; + } + + public void setMentions(Map mentions) { + this.mentions = mentions; + } + + public List getPostHashtags() { + return postHashtags; + } + + public void setPostHashtags(List postHashtags) { + this.postHashtags = postHashtags; + } + + public List getPostLikes() { + return postLikes; + } + + public void setPostLikes(List postLikes) { + this.postLikes = postLikes; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Posts posts = (Posts) o; + return Objects.equals(id, posts.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return "Posts{" + "id=" + id + ", text='" + text + '\'' + '}'; + } } diff --git a/src/main/java/xyz/subho/clone/twitter/entity/RefreshToken.java b/src/main/java/xyz/subho/clone/twitter/entity/RefreshToken.java new file mode 100644 index 0000000..5f03bfb --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/entity/RefreshToken.java @@ -0,0 +1,96 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +@Entity +@Table(name = "refresh_tokens") +public class RefreshToken extends Auditable { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(columnDefinition = "BINARY(16)") + private UUID id; + + @OneToOne + @JoinColumn(name = "users_id", referencedColumnName = "id") + private Users users; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private Instant expiryDate; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Users getUsers() { + return users; + } + + public void setUsers(Users users) { + this.users = users; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Instant expiryDate) { + this.expiryDate = expiryDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RefreshToken that = (RefreshToken) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/entity/Users.java b/src/main/java/xyz/subho/clone/twitter/entity/Users.java index 3dfdff2..94c123f 100644 --- a/src/main/java/xyz/subho/clone/twitter/entity/Users.java +++ b/src/main/java/xyz/subho/clone/twitter/entity/Users.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -31,21 +30,15 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.Objects; import java.util.UUID; -import lombok.Data; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; @Entity @Table( name = "users", indexes = {@Index(columnList = "username")}) -@Data -public class Users { +public class Users extends Auditable { @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -63,6 +56,9 @@ public class Users { @Column(unique = true) private String email; + @Column(nullable = false) + private String password; + @Column(length = 240) private String bio; @@ -75,35 +71,158 @@ public class Users { @Column(columnDefinition = "boolean default false", nullable = false) private Boolean verified = false; - @CreationTimestamp private Date createdAt; - - @UpdateTimestamp private Date updatedAt; - @OneToMany(mappedBy = "users", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @JsonIgnore private List userLikes = new ArrayList<>(); - @ElementCollection private Map follower = new HashMap<>(); + @OneToMany(mappedBy = "following", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JsonIgnore + private List followers = new ArrayList<>(); - @ElementCollection private Map following = new HashMap<>(); + @OneToMany(mappedBy = "follower", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JsonIgnore + private List following = new ArrayList<>(); @OneToMany(mappedBy = "users", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @JsonIgnore private List userPosts = new ArrayList<>(); - public void setFollower(final UUID userId) { - follower.put(userId, new Date()); + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getBio() { + return bio; + } + + public void setBio(String bio) { + this.bio = bio; + } + + public long getFollowerCount() { + return followerCount; + } + + public void setFollowerCount(long followerCount) { + this.followerCount = followerCount; + } + + public Long getFollowingCount() { + return followingCount; + } + + public void setFollowingCount(Long followingCount) { + this.followingCount = followingCount; + } + + public Boolean getVerified() { + return verified; + } + + public void setVerified(Boolean verified) { + this.verified = verified; + } + + public List getUserLikes() { + return userLikes; + } + + public void setUserLikes(List userLikes) { + this.userLikes = userLikes; + } + + public List getFollowers() { + return followers; + } + + public void setFollowers(List followers) { + this.followers = followers; + } + + public List getFollowing() { + return following; + } + + public void setFollowing(List following) { + this.following = following; + } + + public List getUserPosts() { + return userPosts; + } + + public void setUserPosts(List userPosts) { + this.userPosts = userPosts; } - public void setFollowing(final UUID userId) { - following.put(userId, new Date()); + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Users users = (Users) o; + return Objects.equals(id, users.id); } - public void removeFollower(final UUID userId) { - follower.remove(userId); + @Override + public int hashCode() { + return Objects.hashCode(id); } - public void removeFollowing(final UUID userId) { - following.remove(userId); + @Override + public String toString() { + return "Users{" + + "id=" + + id + + ", username='" + + username + + '\'' + + ", name='" + + name + + '\'' + + '}'; } } diff --git a/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java b/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java index 2f6dd61..eaf7ecb 100644 --- a/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java +++ b/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java @@ -1,6 +1,6 @@ /* * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler - * Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -21,6 +21,8 @@ import java.util.Date; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; @@ -29,17 +31,63 @@ @ControllerAdvice public class ControllerExceptionHandler { + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity resourceNotFoundExceptionHandler( + ResourceNotFoundException ex, WebRequest request) { + var errorResponse = + new ErrorResponse( + HttpStatus.NOT_FOUND.value(), + new Date(), + ex.getMessage(), + request.getDescription(false)); + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity badRequestExceptionHandler( + BadRequestException ex, WebRequest request) { + var errorResponse = + new ErrorResponse( + HttpStatus.BAD_REQUEST.value(), + new Date(), + ex.getMessage(), + request.getDescription(false)); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity validationExceptionHandler( + MethodArgumentNotValidException ex, WebRequest request) { + var errorResponse = + new ErrorResponse( + HttpStatus.BAD_REQUEST.value(), + new Date(), + "Validation Failed: " + ex.getBindingResult().toString(), + request.getDescription(false)); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity accessDeniedExceptionHandler( + AccessDeniedException ex, WebRequest request) { + var errorResponse = + new ErrorResponse( + HttpStatus.FORBIDDEN.value(), + new Date(), + "Access Denied: " + ex.getMessage(), + request.getDescription(false)); + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } + @ExceptionHandler(Exception.class) public ResponseEntity globalExceptionHandler( Exception exception, WebRequest request) { - var errorResponse = new ErrorResponse( HttpStatus.INTERNAL_SERVER_ERROR.value(), - new Date(System.currentTimeMillis()), + new Date(), exception.getMessage(), request.getDescription(false)); - - return new ResponseEntity(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/xyz/subho/clone/twitter/model/AuthenticationRequest.java b/src/main/java/xyz/subho/clone/twitter/model/AuthenticationRequest.java index 0925b01..6d0c833 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/AuthenticationRequest.java +++ b/src/main/java/xyz/subho/clone/twitter/model/AuthenticationRequest.java @@ -18,11 +18,4 @@ package xyz.subho.clone.twitter.model; -import lombok.Value; - -@Value -public class AuthenticationRequest { - - private String username; - private String password; -} +public record AuthenticationRequest(String username, String password) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/AuthenticationResponse.java b/src/main/java/xyz/subho/clone/twitter/model/AuthenticationResponse.java index 2cd06b2..a4916bb 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/AuthenticationResponse.java +++ b/src/main/java/xyz/subho/clone/twitter/model/AuthenticationResponse.java @@ -18,10 +18,4 @@ package xyz.subho.clone.twitter.model; -import lombok.Value; - -@Value -public class AuthenticationResponse { - - private final String jwt; -} +public record AuthenticationResponse(String jwt, String refreshToken) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/ErrorResponse.java b/src/main/java/xyz/subho/clone/twitter/model/ErrorResponse.java index e76341c..bb7d804 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/ErrorResponse.java +++ b/src/main/java/xyz/subho/clone/twitter/model/ErrorResponse.java @@ -20,61 +20,5 @@ import java.util.Date; -public class ErrorResponse { - - private Integer statusCode; - private Date timestamp; - private String message; - private String description; - - /** - * @param statusCode - * @param timestamp - * @param message - * @param description - */ - public ErrorResponse(Integer statusCode, Date timestamp, String message, String description) { - this.statusCode = statusCode; - this.timestamp = timestamp; - this.message = message; - this.description = description; - } - - /** - * @return the statusCode - */ - public Integer getStatusCode() { - return statusCode; - } - - /** - * @return the timestamp - */ - public Date getTimestamp() { - return timestamp; - } - - /** - * @return the message - */ - public String getMessage() { - return message; - } - - /** - * @return the description - */ - public String getDescription() { - return description; - } - - @Override - public String toString() { - return "ErrorResponse [" - + (statusCode != null ? "statusCode=" + statusCode + ", " : "") - + (timestamp != null ? "timestamp=" + timestamp + ", " : "") - + (message != null ? "message=" + message + ", " : "") - + (description != null ? "description=" + description : "") - + "]"; - } -} +public record ErrorResponse( + Integer statusCode, Date timestamp, String message, String description) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/HashtagModel.java b/src/main/java/xyz/subho/clone/twitter/model/HashtagModel.java index 6f9fd87..8e4f1bc 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/HashtagModel.java +++ b/src/main/java/xyz/subho/clone/twitter/model/HashtagModel.java @@ -19,12 +19,5 @@ package xyz.subho.clone.twitter.model; import java.util.UUID; -import lombok.Data; -@Data -public class HashtagModel { - - private UUID id; - private String tag; - private Long recentPostCount; -} +public record HashtagModel(UUID id, String tag, Long recentPostCount) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/HashtagPostModel.java b/src/main/java/xyz/subho/clone/twitter/model/HashtagPostModel.java index c0bd403..2d6140f 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/HashtagPostModel.java +++ b/src/main/java/xyz/subho/clone/twitter/model/HashtagPostModel.java @@ -19,12 +19,5 @@ package xyz.subho.clone.twitter.model; import java.util.UUID; -import lombok.Data; -@Data -public class HashtagPostModel { - - private UUID id; - private HashtagModel hashtag; - private PostModel post; -} +public record HashtagPostModel(UUID id, HashtagModel hashtag, PostModel post) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/LikeModel.java b/src/main/java/xyz/subho/clone/twitter/model/LikeModel.java index c8e8e5c..0b5c67b 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/LikeModel.java +++ b/src/main/java/xyz/subho/clone/twitter/model/LikeModel.java @@ -19,12 +19,5 @@ package xyz.subho.clone.twitter.model; import java.util.UUID; -import lombok.Data; -@Data -public class LikeModel { - - private UUID id; - private PostModel post; - private UserModel user; -} +public record LikeModel(UUID id, PostModel post, UserModel user) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/PostModel.java b/src/main/java/xyz/subho/clone/twitter/model/PostModel.java index 9a452ea..f159b22 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/PostModel.java +++ b/src/main/java/xyz/subho/clone/twitter/model/PostModel.java @@ -18,24 +18,32 @@ package xyz.subho.clone.twitter.model; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.UUID; -import lombok.Data; +import org.jspecify.annotations.Nullable; -@Data -public class PostModel { +public record PostModel( + @Nullable UUID id, + @NotBlank(message = "Post text cannot be empty") + @Size(max = 240, message = "Post text cannot exceed 240 characters") + String text, + @Nullable UUID userId, + List images, + @Nullable Long likeCount, + @Nullable Long repostCount, + @Nullable UUID originalPostId, + @Nullable UUID replyToId, + @Nullable Date timestamp, + List hashtags, + List mentions) { - private UUID id; - private String text; - private UUID userId; - private List images = new ArrayList<>(4); - private Long likeCount; - private Long repostCount; - private UUID originalPostId; - private UUID replyToId; - private Date timestamp; - private List hashtags = new ArrayList<>(); - private List mentions = new ArrayList<>(); + public PostModel { + if (images == null) images = new ArrayList<>(4); + if (hashtags == null) hashtags = new ArrayList<>(); + if (mentions == null) mentions = new ArrayList<>(); + } } diff --git a/src/main/java/xyz/subho/clone/twitter/utility/Mapper.java b/src/main/java/xyz/subho/clone/twitter/model/TokenRefreshRequest.java similarity index 77% rename from src/main/java/xyz/subho/clone/twitter/utility/Mapper.java rename to src/main/java/xyz/subho/clone/twitter/model/TokenRefreshRequest.java index 5a8efad..46517bb 100644 --- a/src/main/java/xyz/subho/clone/twitter/utility/Mapper.java +++ b/src/main/java/xyz/subho/clone/twitter/model/TokenRefreshRequest.java @@ -1,6 +1,6 @@ /* * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler - * Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -16,11 +16,8 @@ * along with this program. If not, see . */ -package xyz.subho.clone.twitter.utility; +package xyz.subho.clone.twitter.model; -public interface Mapper { +import jakarta.validation.constraints.NotBlank; - T transform(S source); - - S transformBack(T source); -} +public record TokenRefreshRequest(@NotBlank String refreshToken) {} diff --git a/src/main/java/xyz/subho/clone/twitter/model/UserModel.java b/src/main/java/xyz/subho/clone/twitter/model/UserModel.java index 691e0c9..b4bd82e 100644 --- a/src/main/java/xyz/subho/clone/twitter/model/UserModel.java +++ b/src/main/java/xyz/subho/clone/twitter/model/UserModel.java @@ -18,28 +18,25 @@ package xyz.subho.clone.twitter.model; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.util.UUID; -import lombok.Data; import org.jspecify.annotations.Nullable; -@Data -public class UserModel { - - private @Nullable UUID id; - - @NotBlank(message = "Username is mandatory") - @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") - private String username; - - @NotBlank(message = "Name is mandatory") - @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") - private String name; - - private @Nullable String avatar; - private @Nullable String bio; - private @Nullable Long followerCount; - private @Nullable Long followingCount; - private @Nullable Boolean verified; -} +public record UserModel( + @Nullable UUID id, + @NotBlank(message = "Username is mandatory") + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + String username, + @NotBlank(message = "Name is mandatory") + @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") + String name, + @NotBlank(message = "Password is mandatory") String password, + @NotBlank(message = "Email is mandatory") @Email(message = "Email should be valid") + String email, + @Nullable String avatar, + @Nullable String bio, + @Nullable Long followerCount, + @Nullable Long followingCount, + @Nullable Boolean verified) {} diff --git a/src/main/java/xyz/subho/clone/twitter/repository/FollowRepository.java b/src/main/java/xyz/subho/clone/twitter/repository/FollowRepository.java new file mode 100644 index 0000000..3c9759d --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/repository/FollowRepository.java @@ -0,0 +1,37 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.repository; + +import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import xyz.subho.clone.twitter.entity.Follow; +import xyz.subho.clone.twitter.entity.Users; + +public interface FollowRepository extends JpaRepository { + + Page findByFollowing(Users following, Pageable pageable); + + Page findByFollower(Users follower, Pageable pageable); + + void deleteByFollowerAndFollowing(Users follower, Users following); + + boolean existsByFollowerAndFollowing(Users follower, Users following); +} diff --git a/src/main/java/xyz/subho/clone/twitter/repository/HashtagPostsRepository.java b/src/main/java/xyz/subho/clone/twitter/repository/HashtagPostsRepository.java index 2ce9434..7bf51d1 100644 --- a/src/main/java/xyz/subho/clone/twitter/repository/HashtagPostsRepository.java +++ b/src/main/java/xyz/subho/clone/twitter/repository/HashtagPostsRepository.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import xyz.subho.clone.twitter.entity.HashtagPosts; import xyz.subho.clone.twitter.entity.Hashtags; @@ -27,7 +29,7 @@ public interface HashtagPostsRepository extends JpaRepository { - public List findByHashtags(Hashtags hashtag); + public Page findByHashtags(Hashtags hashtag, Pageable pageable); public List findByPosts(Posts post); } diff --git a/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java b/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java index 9be01df..2f48151 100644 --- a/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java +++ b/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java @@ -19,7 +19,12 @@ package xyz.subho.clone.twitter.repository; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import xyz.subho.clone.twitter.entity.Posts; -public interface PostsRepository extends JpaRepository {} +public interface PostsRepository extends JpaRepository { + + public Page findByReplyToId(UUID replyToId, Pageable pageable); +} diff --git a/src/main/java/xyz/subho/clone/twitter/repository/RefreshTokenRepository.java b/src/main/java/xyz/subho/clone/twitter/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..7816a54 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/repository/RefreshTokenRepository.java @@ -0,0 +1,34 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.repository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import xyz.subho.clone.twitter.entity.RefreshToken; +import xyz.subho.clone.twitter.entity.Users; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + @Modifying + int deleteByUsers(Users user); +} diff --git a/src/main/java/xyz/subho/clone/twitter/security/JwtRequestFilter.java b/src/main/java/xyz/subho/clone/twitter/security/JwtRequestFilter.java index dbd2e8c..3d2a02b 100644 --- a/src/main/java/xyz/subho/clone/twitter/security/JwtRequestFilter.java +++ b/src/main/java/xyz/subho/clone/twitter/security/JwtRequestFilter.java @@ -23,7 +23,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; @@ -33,9 +32,13 @@ @Component public class JwtRequestFilter extends OncePerRequestFilter { - @Autowired private UserDetailsServiceImpl userDetailsService; + private final UserDetailsServiceImpl userDetailsService; + private final JwtUtil jwtUtil; - @Autowired private JwtUtil jwtUtil; + public JwtRequestFilter(UserDetailsServiceImpl userDetailsService, JwtUtil jwtUtil) { + this.userDetailsService = userDetailsService; + this.jwtUtil = jwtUtil; + } @Override protected void doFilterInternal( diff --git a/src/main/java/xyz/subho/clone/twitter/security/SecurityConfig.java b/src/main/java/xyz/subho/clone/twitter/security/SecurityConfig.java index 3e33a4a..a4233aa 100644 --- a/src/main/java/xyz/subho/clone/twitter/security/SecurityConfig.java +++ b/src/main/java/xyz/subho/clone/twitter/security/SecurityConfig.java @@ -18,7 +18,6 @@ package xyz.subho.clone.twitter.security; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -26,23 +25,33 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import xyz.subho.clone.twitter.constant.AuthV1Constants; +import xyz.subho.clone.twitter.constant.UserV1Constants; @Configuration @EnableWebSecurity public class SecurityConfig { - @Autowired private JwtRequestFilter jwtRequestFilter; + private final JwtRequestFilter jwtRequestFilter; + + public SecurityConfig(JwtRequestFilter jwtRequestFilter) { + this.jwtRequestFilter = jwtRequestFilter; + } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .authorizeHttpRequests( auth -> - auth.requestMatchers("/authenticate", "/users", "/swagger-ui/**", "/v3/api-docs/**") + auth.requestMatchers( + AuthV1Constants.BASE_PATH + AuthV1Constants.AUTHENTICATE, + UserV1Constants.BASE_PATH, + "/swagger-ui/**", + "/v3/api-docs/**") .permitAll() .anyRequest() .authenticated()) @@ -56,7 +65,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public PasswordEncoder passwordEncoder() { - return NoOpPasswordEncoder.getInstance(); + return new BCryptPasswordEncoder(); } @Bean diff --git a/src/main/java/xyz/subho/clone/twitter/security/UserDetailsServiceImpl.java b/src/main/java/xyz/subho/clone/twitter/security/UserDetailsServiceImpl.java index 488a310..52859e3 100644 --- a/src/main/java/xyz/subho/clone/twitter/security/UserDetailsServiceImpl.java +++ b/src/main/java/xyz/subho/clone/twitter/security/UserDetailsServiceImpl.java @@ -19,7 +19,6 @@ package xyz.subho.clone.twitter.security; import java.util.ArrayList; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -30,7 +29,11 @@ @Service public class UserDetailsServiceImpl implements UserDetailsService { - @Autowired private UsersRepository userRepository; + private final UsersRepository userRepository; + + public UserDetailsServiceImpl(UsersRepository userRepository) { + this.userRepository = userRepository; + } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { @@ -38,6 +41,6 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx if (user == null) { throw new UsernameNotFoundException("User not found with username: " + username); } - return new User(user.getUsername(), "", new ArrayList<>()); + return new User(user.getUsername(), user.getPassword(), new ArrayList<>()); } } diff --git a/src/main/java/xyz/subho/clone/twitter/service/HashtagService.java b/src/main/java/xyz/subho/clone/twitter/service/HashtagService.java index 5f9ad0c..53cb90f 100644 --- a/src/main/java/xyz/subho/clone/twitter/service/HashtagService.java +++ b/src/main/java/xyz/subho/clone/twitter/service/HashtagService.java @@ -19,15 +19,19 @@ package xyz.subho.clone.twitter.service; import java.util.List; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import xyz.subho.clone.twitter.entity.Hashtags; import xyz.subho.clone.twitter.model.HashtagModel; import xyz.subho.clone.twitter.model.PostModel; public interface HashtagService { - public List getHashtags(); + public @NonNull Page getHashtags(@NonNull Pageable pageable); - public List getPosts(String tag); + public @NonNull Page getPosts(@NonNull String tag, @NonNull Pageable pageable); - public List getHashtagsByTags(List hashtag); + public @Nullable List getHashtagsByTags(@NonNull List hashtag); } diff --git a/src/main/java/xyz/subho/clone/twitter/service/PostService.java b/src/main/java/xyz/subho/clone/twitter/service/PostService.java index f85042f..2853b76 100644 --- a/src/main/java/xyz/subho/clone/twitter/service/PostService.java +++ b/src/main/java/xyz/subho/clone/twitter/service/PostService.java @@ -19,21 +19,25 @@ package xyz.subho.clone.twitter.service; import java.util.UUID; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import xyz.subho.clone.twitter.model.PostModel; public interface PostService { - public Page getAllPosts(Pageable pageable); + public @NonNull Page getAllPosts(@NonNull Pageable pageable); - public PostModel getPost(UUID postId); + public @Nullable PostModel getPost(@NonNull UUID postId); - public PostModel addPost(PostModel postModel); + public @NonNull PostModel addPost(@NonNull PostModel postModel); - public boolean deletePost(UUID postId, UUID userId); + public boolean deletePost(@NonNull UUID postId, @NonNull UUID userId); - public long addLike(UUID postId, UUID userId); + public long addLike(@NonNull UUID postId, @NonNull UUID userId); - public long removeLike(UUID postId, UUID userId); + public long removeLike(@NonNull UUID postId, @NonNull UUID userId); + + public @NonNull Page getReplies(@NonNull UUID postId, @NonNull Pageable pageable); } diff --git a/src/main/java/xyz/subho/clone/twitter/service/RefreshTokenService.java b/src/main/java/xyz/subho/clone/twitter/service/RefreshTokenService.java new file mode 100644 index 0000000..f7f51ec --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/service/RefreshTokenService.java @@ -0,0 +1,35 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.service; + +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NonNull; +import xyz.subho.clone.twitter.entity.RefreshToken; + +public interface RefreshTokenService { + + public RefreshToken createRefreshToken(@NonNull UUID userId); + + public Optional findByToken(@NonNull String token); + + public RefreshToken verifyExpiration(@NonNull RefreshToken token); + + public int deleteByUserId(@NonNull UUID userId); +} diff --git a/src/main/java/xyz/subho/clone/twitter/service/impl/HashtagServiceImpl.java b/src/main/java/xyz/subho/clone/twitter/service/impl/HashtagServiceImpl.java index f6409f9..9f856f0 100644 --- a/src/main/java/xyz/subho/clone/twitter/service/impl/HashtagServiceImpl.java +++ b/src/main/java/xyz/subho/clone/twitter/service/impl/HashtagServiceImpl.java @@ -24,63 +24,60 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; import xyz.subho.clone.twitter.entity.Hashtags; -import xyz.subho.clone.twitter.entity.Posts; import xyz.subho.clone.twitter.model.HashtagModel; import xyz.subho.clone.twitter.model.PostModel; import xyz.subho.clone.twitter.repository.HashtagPostsRepository; import xyz.subho.clone.twitter.repository.HashtagsRepository; import xyz.subho.clone.twitter.service.HashtagService; -import xyz.subho.clone.twitter.utility.Mapper; +import xyz.subho.clone.twitter.utility.HashtagMapper; +import xyz.subho.clone.twitter.utility.PostMapper; @Service public class HashtagServiceImpl implements HashtagService { - @Autowired private HashtagsRepository hashtagsRepository; - - @Autowired private HashtagPostsRepository hashtagPostsRepository; - - @Autowired - @Qualifier("HashtagMapper") - private Mapper hashtagMapper; - - @Autowired - @Qualifier("PostMapper") - private Mapper postMapper; + private final HashtagsRepository hashtagsRepository; + private final HashtagPostsRepository hashtagPostsRepository; + private final HashtagMapper hashtagMapper; + private final PostMapper postMapper; + + public HashtagServiceImpl( + HashtagsRepository hashtagsRepository, + HashtagPostsRepository hashtagPostsRepository, + HashtagMapper hashtagMapper, + PostMapper postMapper) { + this.hashtagsRepository = hashtagsRepository; + this.hashtagPostsRepository = hashtagPostsRepository; + this.hashtagMapper = hashtagMapper; + this.postMapper = postMapper; + } @Override - public List getHashtags() { - - var hashtags = hashtagsRepository.findAll(); - List hashtagModels = new ArrayList<>(); - Optional.ofNullable(hashtags) - .ifPresent( - hashtag -> hashtag.forEach(hTag -> hashtagModels.add(hashtagMapper.transform(hTag)))); - return hashtagModels; - } // TODO: Create a stored procedure in DB. + public @NonNull Page getHashtags(@NonNull Pageable pageable) { + var hashtagsPage = hashtagsRepository.findAll(pageable); + return hashtagsPage.map(hashtagMapper::toModel); + } @Override - public List getPosts(String tag) { - + public @NonNull Page getPosts(@NonNull String tag, @NonNull Pageable pageable) { var hashtag = hashtagsRepository.findByTag(tag); - List posts = new ArrayList<>(); - if (null != hashtag) { - posts = hashtagPostsRepository.findByHashtags(hashtag); + if (null == hashtag) { + return Page.empty(); } - List postModels = new ArrayList<>(); - Optional.ofNullable(posts) - .ifPresent(post -> post.forEach(pst -> postModels.add(postMapper.transform(pst)))); - return postModels; + var hashtagPostsPage = hashtagPostsRepository.findByHashtags(hashtag, pageable); + return hashtagPostsPage.map(hp -> postMapper.toModel(hp.getPosts())); } @Override @Transactional - public List getHashtagsByTags(List tags) { + public @Nullable List getHashtagsByTags(@NonNull List tags) { List outputListOfHashtags = new ArrayList<>(); List hashTags = hashtagsRepository.findByTagIn(tags); @@ -115,7 +112,7 @@ private void setHashTagCount(List hashTags) { private Set fetchExistingTags(List hashTags) { if (!CollectionUtils.isEmpty(hashTags)) { - return hashTags.stream().map(hTag -> hTag.getTag()).collect(Collectors.toSet()); + return hashTags.stream().map(Hashtags::getTag).collect(Collectors.toSet()); } return new HashSet<>(); } diff --git a/src/main/java/xyz/subho/clone/twitter/service/impl/PostServiceImpl.java b/src/main/java/xyz/subho/clone/twitter/service/impl/PostServiceImpl.java index 18e8f3e..e5ac63a 100644 --- a/src/main/java/xyz/subho/clone/twitter/service/impl/PostServiceImpl.java +++ b/src/main/java/xyz/subho/clone/twitter/service/impl/PostServiceImpl.java @@ -22,21 +22,19 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import xyz.subho.clone.twitter.entity.HashtagPosts; import xyz.subho.clone.twitter.entity.Likes; -import xyz.subho.clone.twitter.entity.Posts; -import xyz.subho.clone.twitter.entity.Users; import xyz.subho.clone.twitter.exception.ErrorSavingEntityToDatabaseException; import xyz.subho.clone.twitter.exception.ResourceNotFoundException; import xyz.subho.clone.twitter.model.PostModel; -import xyz.subho.clone.twitter.model.UserModel; import xyz.subho.clone.twitter.repository.HashtagPostsRepository; import xyz.subho.clone.twitter.repository.LikesRepository; import xyz.subho.clone.twitter.repository.PostsRepository; @@ -44,54 +42,64 @@ import xyz.subho.clone.twitter.service.HashtagService; import xyz.subho.clone.twitter.service.PostService; import xyz.subho.clone.twitter.service.UserService; -import xyz.subho.clone.twitter.utility.Mapper; +import xyz.subho.clone.twitter.utility.PostMapper; +import xyz.subho.clone.twitter.utility.UserMapper; @Service -@Slf4j public class PostServiceImpl implements PostService { - @Autowired private PostsRepository postsRepository; - - @Autowired private HashtagPostsRepository hashtagPostRepository; - - @Autowired private UserService userService; - - @Autowired private HashtagService hashtagService; - - @Autowired private LikesRepository likeRepository; - - @Autowired private UsersRepository usersRepository; - - @Autowired - @Qualifier("PostMapper") - private Mapper postMapper; - - @Autowired - @Qualifier("UserMapper") - private Mapper userMapper; + private static final Logger log = LoggerFactory.getLogger(PostServiceImpl.class); + + private final PostsRepository postsRepository; + private final HashtagPostsRepository hashtagPostRepository; + private final UserService userService; + private final HashtagService hashtagService; + private final LikesRepository likeRepository; + private final UsersRepository usersRepository; + private final PostMapper postMapper; + private final UserMapper userMapper; + + public PostServiceImpl( + PostsRepository postsRepository, + HashtagPostsRepository hashtagPostRepository, + UserService userService, + HashtagService hashtagService, + LikesRepository likeRepository, + UsersRepository usersRepository, + PostMapper postMapper, + UserMapper userMapper) { + this.postsRepository = postsRepository; + this.hashtagPostRepository = hashtagPostRepository; + this.userService = userService; + this.hashtagService = hashtagService; + this.likeRepository = likeRepository; + this.usersRepository = usersRepository; + this.postMapper = postMapper; + this.userMapper = userMapper; + } @Override - public Page getAllPosts(Pageable pageable) { + public @NonNull Page getAllPosts(@NonNull Pageable pageable) { var postsPage = postsRepository.findAll(pageable); - return postsPage.map(postMapper::transform); + return postsPage.map(postMapper::toModel); } @Override - public PostModel getPost(UUID postId) { + public @Nullable PostModel getPost(@NonNull UUID postId) { var post = postsRepository.findById(postId); - if (post.isPresent()) return postMapper.transform(post.get()); + if (post.isPresent()) return postMapper.toModel(post.get()); throw new ResourceNotFoundException("Post ID is Invalid"); } @Override @Transactional - public PostModel addPost(PostModel postModel) { + public @NonNull PostModel addPost(@NonNull PostModel postModel) { List hashtagPosts = new ArrayList<>(); - var post = postMapper.transformBack(postModel); - post.setUsers(usersRepository.getById(postModel.getUserId())); - Optional.ofNullable(hashtagService.getHashtagsByTags(postModel.getHashtags())) + var post = postMapper.toEntity(postModel); + post.setUsers(usersRepository.getById(postModel.userId())); + Optional.ofNullable(hashtagService.getHashtagsByTags(postModel.hashtags())) .ifPresent( hashtags -> { hashtags.forEach( @@ -104,12 +112,12 @@ public PostModel addPost(PostModel postModel) { }); post.setPostHashtags(hashtagPosts); hashtagPostRepository.saveAll(hashtagPosts); - return postMapper.transform(postsRepository.save(post)); + return postMapper.toModel(postsRepository.save(post)); } @Override @Transactional - public boolean deletePost(UUID postId, UUID userId) { + public boolean deletePost(@NonNull UUID postId, @NonNull UUID userId) { if (Optional.ofNullable((getPost(postId))).isPresent()) { postsRepository.deleteById(postId); @@ -120,11 +128,15 @@ public boolean deletePost(UUID postId, UUID userId) { @Override @Transactional - public long addLike(UUID postId, UUID userId) { + public long addLike(@NonNull UUID postId, @NonNull UUID userId) { - var post = postMapper.transformBack(getPost(postId)); + var postModel = getPost(postId); + if (postModel == null) throw new ResourceNotFoundException("Post not found"); + var post = postMapper.toEntity(postModel); post.incrementLikeCount(); - var user = userMapper.transformBack(userService.getUserByUserId(userId)); + var userModel = userService.getUserByUserId(userId); + if (userModel == null) throw new ResourceNotFoundException("User not found"); + var user = userMapper.toEntity(userModel); var likeMapping = new Likes(); likeMapping.setPosts(post); @@ -141,11 +153,15 @@ public long addLike(UUID postId, UUID userId) { @Override @Transactional - public long removeLike(UUID postId, UUID userId) { + public long removeLike(@NonNull UUID postId, @NonNull UUID userId) { - var post = postMapper.transformBack(getPost(postId)); + var postModel = getPost(postId); + if (postModel == null) throw new ResourceNotFoundException("Post not found"); + var post = postMapper.toEntity(postModel); post.decrementLikeCount(); - var user = userMapper.transformBack(userService.getUserByUserId(userId)); + var userModel = userService.getUserByUserId(userId); + if (userModel == null) throw new ResourceNotFoundException("User not found"); + var user = userMapper.toEntity(userModel); try { likeRepository.deleteByPostsAndUsers(post, user); @@ -156,4 +172,10 @@ public long removeLike(UUID postId, UUID userId) { throw new ErrorSavingEntityToDatabaseException("Cannot Save to Database"); } } + + @Override + public @NonNull Page getReplies(@NonNull UUID postId, @NonNull Pageable pageable) { + var repliesPage = postsRepository.findByReplyToId(postId, pageable); + return repliesPage.map(postMapper::toModel); + } } diff --git a/src/main/java/xyz/subho/clone/twitter/service/impl/RefreshTokenServiceImpl.java b/src/main/java/xyz/subho/clone/twitter/service/impl/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..6969a1f --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/service/impl/RefreshTokenServiceImpl.java @@ -0,0 +1,84 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.service.impl; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NonNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import xyz.subho.clone.twitter.entity.RefreshToken; +import xyz.subho.clone.twitter.exception.BadRequestException; +import xyz.subho.clone.twitter.repository.RefreshTokenRepository; +import xyz.subho.clone.twitter.repository.UsersRepository; +import xyz.subho.clone.twitter.service.RefreshTokenService; + +@Service +public class RefreshTokenServiceImpl implements RefreshTokenService { + + @Value("${jwt.refreshExpiration}") + private Long refreshTokenDurationMs; + + private final RefreshTokenRepository refreshTokenRepository; + private final UsersRepository usersRepository; + + public RefreshTokenServiceImpl( + RefreshTokenRepository refreshTokenRepository, UsersRepository usersRepository) { + this.refreshTokenRepository = refreshTokenRepository; + this.usersRepository = usersRepository; + } + + @Override + @Transactional + public RefreshToken createRefreshToken(@NonNull UUID userId) { + var user = usersRepository.getById(userId); + + // Clean up existing tokens for the user + refreshTokenRepository.deleteByUsers(user); + + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setUsers(user); + refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs)); + refreshToken.setToken(UUID.randomUUID().toString()); + + return refreshTokenRepository.save(refreshToken); + } + + @Override + public Optional findByToken(@NonNull String token) { + return refreshTokenRepository.findByToken(token); + } + + @Override + public RefreshToken verifyExpiration(@NonNull RefreshToken token) { + if (token.getExpiryDate().compareTo(Instant.now()) < 0) { + refreshTokenRepository.delete(token); + throw new BadRequestException("Refresh token was expired. Please make a new signin request"); + } + return token; + } + + @Override + @Transactional + public int deleteByUserId(@NonNull UUID userId) { + return refreshTokenRepository.deleteByUsers(usersRepository.getById(userId)); + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/service/impl/UserServiceImpl.java b/src/main/java/xyz/subho/clone/twitter/service/impl/UserServiceImpl.java index 106b16a..ef9c61f 100644 --- a/src/main/java/xyz/subho/clone/twitter/service/impl/UserServiceImpl.java +++ b/src/main/java/xyz/subho/clone/twitter/service/impl/UserServiceImpl.java @@ -19,85 +19,120 @@ package xyz.subho.clone.twitter.service.impl; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import xyz.subho.clone.twitter.entity.Follow; import xyz.subho.clone.twitter.entity.Users; import xyz.subho.clone.twitter.model.UserModel; +import xyz.subho.clone.twitter.repository.FollowRepository; import xyz.subho.clone.twitter.repository.UsersRepository; import xyz.subho.clone.twitter.service.UserService; -import xyz.subho.clone.twitter.utility.Mapper; +import xyz.subho.clone.twitter.utility.UserMapper; @Service public class UserServiceImpl implements UserService { - @Autowired private UsersRepository usersRepository; + private final UsersRepository usersRepository; + private final FollowRepository followRepository; + private final PasswordEncoder passwordEncoder; + private final UserMapper userMapper; - @Autowired - @Qualifier("UserMapper") - private Mapper userMapper; + public UserServiceImpl( + UsersRepository usersRepository, + FollowRepository followRepository, + PasswordEncoder passwordEncoder, + UserMapper userMapper) { + this.usersRepository = usersRepository; + this.followRepository = followRepository; + this.passwordEncoder = passwordEncoder; + this.userMapper = userMapper; + } @Override - public UserModel getUserByUserName(String username) { - return userMapper.transform(usersRepository.findByUsername(username)); + public @Nullable UserModel getUserByUserName(@NonNull String username) { + return userMapper.toModel(usersRepository.findByUsername(username)); } @Override - public UserModel getUserByUserId(UUID userId) { + public @Nullable UserModel getUserByUserId(@NonNull UUID userId) { var user = usersRepository.getById(userId); - return userMapper.transform(user); + return userMapper.toModel(user); } @Override - public Users getUserEntityByUserId(UUID userId) { + public @Nullable Users getUserEntityByUserId(@NonNull UUID userId) { return usersRepository.getById(userId); } @Override @Transactional - public UserModel addUser(UserModel user) { - Users users = userMapper.transformBack(user); - return userMapper.transform(usersRepository.save(users)); + public @NonNull UserModel addUser(@NonNull UserModel userModel) { + var user = userMapper.toEntity(userModel); + user.setPassword(passwordEncoder.encode(userModel.password())); + return userMapper.toModel(usersRepository.save(user)); } @Override @Transactional - public UserModel editUser(UserModel user) { - Users users = userMapper.transformBack(user); - return userMapper.transform(usersRepository.save(users)); + public @NonNull UserModel editUser(@NonNull UserModel userModel) { + Users users = userMapper.toEntity(userModel); + return userMapper.toModel(usersRepository.save(users)); } @Override @Transactional - public boolean addFollower(UUID followerId, UUID userId) { + public boolean addFollower(@NonNull UUID followerId, @NonNull UUID userId) { + if (followRepository.existsByFollowerAndFollowing( + usersRepository.getById(followerId), usersRepository.getById(userId))) { + return false; + } + Users user = usersRepository.getById(userId); - user.setFollower(followerId); + Users follower = usersRepository.getById(followerId); + + Follow follow = new Follow(follower, user); + followRepository.save(follow); + + user.setFollowerCount(user.getFollowerCount() + 1); usersRepository.save(user); + + follower.setFollowingCount(follower.getFollowingCount() + 1); + usersRepository.save(follower); return true; } @Override - public boolean removeFollower(UUID followerId, UUID userId) { + @Transactional + public boolean removeFollower(@NonNull UUID followerId, @NonNull UUID userId) { Users user = usersRepository.getById(userId); - user.removeFollower(followerId); + Users follower = usersRepository.getById(followerId); + + followRepository.deleteByFollowerAndFollowing(follower, user); + + user.setFollowerCount(Math.max(0, user.getFollowerCount() - 1)); usersRepository.save(user); + + follower.setFollowingCount(Math.max(0, follower.getFollowingCount() - 1)); + usersRepository.save(follower); return true; } @Override - public Page getFollowers(UUID userId, Pageable pageable) { + public @NonNull Page getFollowers(@NonNull UUID userId, @NonNull Pageable pageable) { Users user = usersRepository.getById(userId); - var usersPage = usersRepository.findByIdIn(user.getFollower().keySet(), pageable); - return usersPage.map(userMapper::transform); + var followsPage = followRepository.findByFollowing(user, pageable); + return followsPage.map(f -> userMapper.toModel(f.getFollower())); } @Override - public Page getFollowings(UUID userId, Pageable pageable) { + public @NonNull Page getFollowings(@NonNull UUID userId, @NonNull Pageable pageable) { Users user = usersRepository.getById(userId); - var usersPage = usersRepository.findByIdIn(user.getFollowing().keySet(), pageable); - return usersPage.map(userMapper::transform); + var followsPage = followRepository.findByFollower(user, pageable); + return followsPage.map(f -> userMapper.toModel(f.getFollowing())); } } diff --git a/src/main/java/xyz/subho/clone/twitter/utility/HashtagMapper.java b/src/main/java/xyz/subho/clone/twitter/utility/HashtagMapper.java index e86c4d2..531c0a4 100644 --- a/src/main/java/xyz/subho/clone/twitter/utility/HashtagMapper.java +++ b/src/main/java/xyz/subho/clone/twitter/utility/HashtagMapper.java @@ -1,6 +1,6 @@ /* * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler - * Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -18,25 +18,18 @@ package xyz.subho.clone.twitter.utility; -import org.springframework.beans.BeanUtils; -import org.springframework.stereotype.Component; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import xyz.subho.clone.twitter.entity.Hashtags; import xyz.subho.clone.twitter.model.HashtagModel; -@Component("HashtagMapper") -public class HashtagMapper implements Mapper { +@Mapper(componentModel = "spring") +public interface HashtagMapper { - @Override - public HashtagModel transform(Hashtags hashtag) { - var hashtagModel = new HashtagModel(); - BeanUtils.copyProperties(hashtag, hashtagModel); - return hashtagModel; - } + HashtagModel toModel(Hashtags hashtag); - @Override - public Hashtags transformBack(HashtagModel hashtagModel) { - var hashtag = new Hashtags(); - BeanUtils.copyProperties(hashtagModel, hashtag); - return hashtag; - } + @Mapping(target = "hashtagPosts", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + Hashtags toEntity(HashtagModel hashtagModel); } diff --git a/src/main/java/xyz/subho/clone/twitter/utility/PostMapper.java b/src/main/java/xyz/subho/clone/twitter/utility/PostMapper.java index d12bae7..9916b48 100644 --- a/src/main/java/xyz/subho/clone/twitter/utility/PostMapper.java +++ b/src/main/java/xyz/subho/clone/twitter/utility/PostMapper.java @@ -1,6 +1,6 @@ /* * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler - * Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -21,37 +21,46 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; -import org.springframework.beans.BeanUtils; -import org.springframework.stereotype.Component; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; import xyz.subho.clone.twitter.entity.Posts; import xyz.subho.clone.twitter.model.PostModel; -@Component("PostMapper") -public class PostMapper implements Mapper { +@Mapper(componentModel = "spring") +public interface PostMapper { - @Override - public PostModel transform(Posts post) { - PostModel postModel = new PostModel(); - BeanUtils.copyProperties(post, postModel, "hashtags", "mentions"); - postModel.setHashtags(new ArrayList<>(post.getHashtags().keySet())); - postModel.setMentions(new ArrayList<>(post.getMentions().keySet())); - postModel.setUserId(post.getUsers().getId()); - return postModel; + @Mapping(target = "userId", source = "users.id") + @Mapping(target = "hashtags", source = "hashtags", qualifiedByName = "mapToKeysList") + @Mapping(target = "mentions", source = "mentions", qualifiedByName = "mapToKeysList") + @Mapping(target = "images", source = "images", qualifiedByName = "mapToKeysList") + @Mapping(target = "timestamp", source = "createdAt") + PostModel toModel(Posts post); + + @Mapping(target = "users", ignore = true) + @Mapping(target = "postHashtags", ignore = true) + @Mapping(target = "postLikes", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "hashtags", source = "hashtags", qualifiedByName = "listToDateMap") + @Mapping(target = "mentions", source = "mentions", qualifiedByName = "listToDateMap") + @Mapping(target = "images", source = "images", qualifiedByName = "listToDateMap") + Posts toEntity(PostModel postModel); + + @Named("mapToKeysList") + default List mapToKeysList(Map map) { + if (map == null) return new ArrayList<>(); + return new ArrayList<>(map.keySet()); } - @Override - public Posts transformBack(PostModel postModel) { - Posts post = new Posts(); - BeanUtils.copyProperties(postModel, post, "hashtags", "mentions", "likeCount", "repostCount"); - Map hashtags = new HashMap<>(); - Map mentions = new HashMap<>(); - postModel.getHashtags().forEach(tag -> hashtags.put(tag, new Date())); - postModel.getMentions().forEach(mention -> mentions.put(mention, new Date())); - post.setHashtags(hashtags); - post.setMentions(mentions); - post.setLikeCount(null != postModel.getLikeCount() ? postModel.getLikeCount() : 0L); - post.setRepostCount(null != postModel.getRepostCount() ? postModel.getRepostCount() : 0L); - return post; + @Named("listToDateMap") + default Map listToDateMap(List list) { + Map map = new HashMap<>(); + if (list != null) { + list.forEach(item -> map.put(item, new Date())); + } + return map; } } diff --git a/src/main/java/xyz/subho/clone/twitter/utility/UserMapper.java b/src/main/java/xyz/subho/clone/twitter/utility/UserMapper.java index 25dc0f9..6fd9792 100644 --- a/src/main/java/xyz/subho/clone/twitter/utility/UserMapper.java +++ b/src/main/java/xyz/subho/clone/twitter/utility/UserMapper.java @@ -1,6 +1,6 @@ /* * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler - * Copyright © 2021-2023 Subhrodip Mohanta (hello@subho.xyz) + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by @@ -18,29 +18,22 @@ package xyz.subho.clone.twitter.utility; -import org.springframework.beans.BeanUtils; -import org.springframework.stereotype.Component; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import xyz.subho.clone.twitter.entity.Users; import xyz.subho.clone.twitter.model.UserModel; -@Component("UserMapper") -public class UserMapper implements Mapper { +@Mapper(componentModel = "spring") +public interface UserMapper { - @Override - public UserModel transform(Users user) { - var userModel = new UserModel(); - BeanUtils.copyProperties(user, userModel); - return userModel; - } + @Mapping(target = "password", ignore = true) + UserModel toModel(Users user); - @Override - public Users transformBack(UserModel userModel) { - var user = new Users(); - BeanUtils.copyProperties(userModel, user, "followerCount", "followingCount", "verified"); - user.setFollowerCount(userModel.getFollowerCount() != null ? userModel.getFollowerCount() : 0L); - user.setFollowingCount( - userModel.getFollowingCount() != null ? userModel.getFollowingCount() : 0L); - user.setVerified(userModel.getVerified() != null ? userModel.getVerified() : false); - return user; - } + @Mapping(target = "userLikes", ignore = true) + @Mapping(target = "userPosts", ignore = true) + @Mapping(target = "followers", ignore = true) + @Mapping(target = "following", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + Users toEntity(UserModel userModel); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 94204bc..5353a2e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -32,3 +32,4 @@ jwt.secret=9a4f4342453527245a462d4a614e645267556b58703273357638792f423f4528 jwt.expiration=3600000 spring.mvc.apiversion.enabled=true spring.mvc.apiversion.default-version=1 +jwt.refreshExpiration=604800000