diff --git a/.env.example b/.env.example index 5e31dae..82e8d22 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,19 @@ KEYCLOAK_URL=https://keycloak.domain.com KEYCLOAK_ADMIN= KEYCLOAK_ADMIN_PASSWORD= +# RabbitMQ +RABBITMQ_HOST=localhost +RABBITMQ_PORT=5672 +RABBITMQ_USER= +RABBITMQ_PASSWORD= + +# InfluxDB +INFLUX_URL=http://localhost:8086 +INFLUX_USER= +INFLUX_PASSWORD= +INFLUX_TOKEN= +INFLUX_ORG= + # Grafana GRAFANA_USER= GRAFANA_PASSWORD= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21545f8..44c5046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Run backend uses: JarvusInnovations/background-action@v1 with: - run: docker compose -f docker-compose.testing.yml up & + run: docker compose -f docker-compose.test.yml up & wait-on: http://localhost:8080/health/ wait-for: 10m diff --git a/apps/api/pom.xml b/apps/api/pom.xml index 4b42855..34bd9d7 100644 --- a/apps/api/pom.xml +++ b/apps/api/pom.xml @@ -130,6 +130,24 @@ io.micrometer micrometer-registry-prometheus + + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.amqp + spring-rabbit-test + test + + + + + com.influxdb + influxdb-client-java + 7.2.0 + diff --git a/apps/api/src/main/java/com/mally/api/Application.java b/apps/api/src/main/java/com/mally/api/Application.java index 1486371..1104b2a 100644 --- a/apps/api/src/main/java/com/mally/api/Application.java +++ b/apps/api/src/main/java/com/mally/api/Application.java @@ -1,9 +1,11 @@ package com.mally.api; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication +@EnableRabbit public class Application { public static void main(String[] args) { diff --git a/apps/api/src/main/java/com/mally/api/auth/UserJwt.java b/apps/api/src/main/java/com/mally/api/auth/UserJwt.java index 475d6ef..df1845c 100644 --- a/apps/api/src/main/java/com/mally/api/auth/UserJwt.java +++ b/apps/api/src/main/java/com/mally/api/auth/UserJwt.java @@ -1,5 +1,6 @@ package com.mally.api.auth; +import com.mally.api.auth.domain.valueobjects.UserId; import lombok.Getter; import lombok.Setter; import org.springframework.security.core.GrantedAuthority; @@ -21,7 +22,7 @@ public UserJwt(Jwt jwt, Collection authorities) { super(jwt, authorities); } - public String getId() { - return getName(); + public UserId getId() { + return new UserId(getName()); } } diff --git a/apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java b/apps/api/src/main/java/com/mally/api/auth/configuration/WebSecurityConfig.java similarity index 98% rename from apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java rename to apps/api/src/main/java/com/mally/api/auth/configuration/WebSecurityConfig.java index 4c1a073..f6473ff 100644 --- a/apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java +++ b/apps/api/src/main/java/com/mally/api/auth/configuration/WebSecurityConfig.java @@ -1,4 +1,4 @@ -package com.mally.api.configuration; +package com.mally.api.auth.configuration; import com.mally.api.auth.UserJwtConverter; import lombok.RequiredArgsConstructor; diff --git a/apps/api/src/main/java/com/mally/api/auth/domain/valueobjects/UserId.java b/apps/api/src/main/java/com/mally/api/auth/domain/valueobjects/UserId.java new file mode 100644 index 0000000..8f9bfbf --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/auth/domain/valueobjects/UserId.java @@ -0,0 +1,3 @@ +package com.mally.api.auth.domain.valueobjects; + +public record UserId(String value) {} diff --git a/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java b/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java index 46145e6..fc4760d 100644 --- a/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java +++ b/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java @@ -5,7 +5,7 @@ import com.mally.api.pastebin.dtos.CreatePasteDTO; import com.mally.api.pastebin.entities.Paste; import com.mally.api.pastebin.services.PastebinService; -import com.mally.api.shared.rest.dtos.ApiResponseDTO; +import com.mally.api.shared.rest.dtos.ApiResponse; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; @@ -33,7 +33,7 @@ public ResponseEntity> findAll( ) { var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow(); var result = pastebinService.findAll( - userId, + userId.value(), search, PageRequest.of(pageNumber, pageSize, Sort.by(orderBy.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy)) ); @@ -42,37 +42,37 @@ public ResponseEntity> findAll( } @GetMapping("/paste/{slug}") - public ResponseEntity findPaste(@PathVariable String slug) { + public ResponseEntity findPaste(@PathVariable String slug) { return pastebinService.findBySlug(slug) .map(p -> ResponseEntity .ok() - .body(ApiResponseDTO.success("Paste found successfully.", p))) + .body(ApiResponse.success("Paste found successfully.", p))) .orElseGet(() -> ResponseEntity .badRequest() - .body(ApiResponseDTO.error("Paste not found.", List.of()))); + .body(ApiResponse.error("Paste not found.", List.of()))); } @PostMapping("/paste") - public ResponseEntity paste(@Valid @RequestBody CreatePasteDTO dto) { + public ResponseEntity paste(@Valid @RequestBody CreatePasteDTO dto) { var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null); - var paste = pastebinService.create(dto, userId); + var paste = pastebinService.create(dto, userId.value()); return ResponseEntity .ok() - .body(ApiResponseDTO.success("Paste created successfully.", paste)); + .body(ApiResponse.success("Paste created successfully.", paste)); } @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable Long id) { + public ResponseEntity delete(@PathVariable Long id) { pastebinService.delete(id); - return ResponseEntity.ok(ApiResponseDTO.success("Paste deleted successfully.", null)); + return ResponseEntity.ok(ApiResponse.success("Paste deleted successfully.", null)); } @DeleteMapping("bulk") - public ResponseEntity bulkDelete(@RequestParam List id) { + public ResponseEntity bulkDelete(@RequestParam List id) { pastebinService.deleteMany(id); - return ResponseEntity.ok(ApiResponseDTO.success("Pastes deleted successfully.", null)); + return ResponseEntity.ok(ApiResponse.success("Pastes deleted successfully.", null)); } } diff --git a/apps/api/src/main/java/com/mally/api/shared/abstractions/UseCase.java b/apps/api/src/main/java/com/mally/api/shared/abstractions/UseCase.java new file mode 100644 index 0000000..b145747 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/abstractions/UseCase.java @@ -0,0 +1,5 @@ +package com.mally.api.shared.abstractions; + +public interface UseCase { + TResponse execute(TRequest request); +} diff --git a/apps/api/src/main/java/com/mally/api/shared/commands/FindAllCommand.java b/apps/api/src/main/java/com/mally/api/shared/commands/FindAllCommand.java new file mode 100644 index 0000000..ebe4db3 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/commands/FindAllCommand.java @@ -0,0 +1,16 @@ +package com.mally.api.shared.commands; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.shared.utils.PaginationUtils; +import jakarta.annotation.Nullable; +import org.springframework.data.domain.Pageable; + +public record FindAllCommand( + UserId userId, + @Nullable String search, + Pageable pageable +) { + public FindAllCommand(UserId userId, String search, int pageNumber, int pageSize, String orderBy, String sortBy) { + this(userId, search, PaginationUtils.buildPageable(pageNumber, pageSize, orderBy, sortBy)); + } +} diff --git a/apps/api/src/main/java/com/mally/api/shared/configuration/InfluxConfiguration.java b/apps/api/src/main/java/com/mally/api/shared/configuration/InfluxConfiguration.java new file mode 100644 index 0000000..a4d09cf --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/configuration/InfluxConfiguration.java @@ -0,0 +1,25 @@ +package com.mally.api.shared.configuration; + +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.InfluxDBClientFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class InfluxConfiguration { + + @Value("${influx.url}") + private String connectionUrl; + + @Value("${influx.token}") + private String token; + + @Value("${influx.org}") + private String org; + + @Bean + public InfluxDBClient influxDBClient() { + return InfluxDBClientFactory.create(connectionUrl, token.toCharArray(), org); + } +} diff --git a/apps/api/src/main/java/com/mally/api/shared/configuration/InfluxInitializer.java b/apps/api/src/main/java/com/mally/api/shared/configuration/InfluxInitializer.java new file mode 100644 index 0000000..9c874e1 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/configuration/InfluxInitializer.java @@ -0,0 +1,59 @@ +package com.mally.api.shared.configuration; + +import com.influxdb.client.BucketsApi; +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.domain.Bucket; +import com.influxdb.client.domain.Organization; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class InfluxInitializer { + private final InfluxDBClient influxDBClient; + + private final String bucketNames; + + private final String orgName; + + public InfluxInitializer(InfluxDBClient influxDBClient, + @Value("${influx.buckets}") String buckets, + @Value("${influx.org}") String org) { + this.influxDBClient = influxDBClient; + this.bucketNames = buckets; + this.orgName = org; + } + + @PostConstruct + public void init() { + createBucketsIfNotExists(); + } + + private void createBucketsIfNotExists() { + BucketsApi bucketsApi = influxDBClient.getBucketsApi(); + + Organization organization = influxDBClient.getOrganizationsApi() + .findOrganizations().stream() + .filter(org -> org.getName().equals(orgName)) + .findFirst() + .orElseThrow(); + + for (String bucketName : bucketNames.split(",")) { + boolean bucketAlreadyCreated = bucketsApi.findBucketByName(bucketName) != null; + + if (bucketAlreadyCreated) { + log.info("Bucket with name {} already created, skipping...", bucketName); + continue; + } + + Bucket bucket = new Bucket(); + bucket.setName(bucketName); + bucket.setOrgID(organization.getId()); + + bucketsApi.createBucket(bucket); + log.info("Created bucket {}", bucketName); + } + } +} diff --git a/apps/api/src/main/java/com/mally/api/shared/configuration/RabbitMQConfiguration.java b/apps/api/src/main/java/com/mally/api/shared/configuration/RabbitMQConfiguration.java new file mode 100644 index 0000000..2a9d097 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/configuration/RabbitMQConfiguration.java @@ -0,0 +1,22 @@ +package com.mally.api.shared.configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfiguration { + + private final ObjectMapper objectMapper; + + public RabbitMQConfiguration(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Bean + MessageConverter messageConverter() { + return new Jackson2JsonMessageConverter(objectMapper); + } +} diff --git a/apps/api/src/main/java/com/mally/api/shared/repositories/InfluxRepository.java b/apps/api/src/main/java/com/mally/api/shared/repositories/InfluxRepository.java new file mode 100644 index 0000000..7fb906e --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/repositories/InfluxRepository.java @@ -0,0 +1,52 @@ +package com.mally.api.shared.repositories; + +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.domain.WritePrecision; +import com.influxdb.query.FluxRecord; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +@Repository +public class InfluxRepository { + + private final String org; + + private final InfluxDBClient influxDBClient; + + InfluxRepository(InfluxDBClient influxDBClient, @Value("${influx.org}") String org) { + this.influxDBClient = influxDBClient; + this.org = org; + } + + public List query(String query) { + var queryApi = influxDBClient.getQueryApi(); + + var tables = queryApi.query(query); + + var results = new ArrayList(); + + for (var table : tables) { + var records = table.getRecords(); + + results.addAll(records); + } + + return results; + } + + public void writeMeasurement(Object measurement, String bucket) { + influxDBClient.getWriteApiBlocking().writeMeasurement(bucket, org, WritePrecision.MS, measurement); + } + + public void deleteMeasurement(Long measurementId, String bucket) { + var start = OffsetDateTime.now().minusYears(100); + var stop = OffsetDateTime.now(); + var predicate = String.format("id=\"%s\"", measurementId); + + influxDBClient.getDeleteApi().delete(start, stop, predicate, bucket, org); + } +} diff --git a/apps/api/src/main/java/com/mally/api/shared/rest/advices/ErrorHandlingControllerAdvice.java b/apps/api/src/main/java/com/mally/api/shared/rest/advices/ErrorHandlingControllerAdvice.java index 5fa6dfb..52936d5 100644 --- a/apps/api/src/main/java/com/mally/api/shared/rest/advices/ErrorHandlingControllerAdvice.java +++ b/apps/api/src/main/java/com/mally/api/shared/rest/advices/ErrorHandlingControllerAdvice.java @@ -1,6 +1,6 @@ package com.mally.api.shared.rest.advices; -import com.mally.api.shared.rest.dtos.ApiResponseDTO; +import com.mally.api.shared.rest.dtos.ApiResponse; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; @@ -20,20 +20,20 @@ class ErrorHandlingControllerAdvice { @ExceptionHandler(ConstraintViolationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseBody - ApiResponseDTO onConstraintValidationException(ConstraintViolationException e) { + ApiResponse onConstraintValidationException(ConstraintViolationException e) { List error = new ArrayList<>(); for (ConstraintViolation violation : e.getConstraintViolations()) { error.add(violation.getPropertyPath().toString() + ' ' + violation.getMessage()); } - return ApiResponseDTO.error("Validation error", error); + return ApiResponse.error("Validation error", error); } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseBody - ApiResponseDTO onMethodArgumentNotValidException( + ApiResponse onMethodArgumentNotValidException( MethodArgumentNotValidException e) { List error = new ArrayList<>(); @@ -41,7 +41,7 @@ ApiResponseDTO onMethodArgumentNotValidException( error.add(fieldError.getField() + ' ' + fieldError.getDefaultMessage()); } - return ApiResponseDTO.error("Validation error", error); + return ApiResponse.error("Validation error", error); } } diff --git a/apps/api/src/main/java/com/mally/api/shared/rest/base/BaseController.java b/apps/api/src/main/java/com/mally/api/shared/rest/base/BaseController.java deleted file mode 100644 index 533ddf2..0000000 --- a/apps/api/src/main/java/com/mally/api/shared/rest/base/BaseController.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.mally.api.shared.rest.base; - -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; - -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; - -public class BaseController { - protected Pageable getPaginationInformation(Map params) { - var page = Integer.parseInt(params.getOrDefault("page", "0")); - var size = Integer.parseInt(params.getOrDefault("size", "10")); - var sortBy = params.getOrDefault("sortBy", "id"); - var order = params.getOrDefault("order", "desc"); - - return PageRequest.of( - page, - size, - Objects.equals(order, "desc") ? Sort.by(sortBy).descending() : Sort.by(sortBy).ascending() - ); - } -} diff --git a/apps/api/src/main/java/com/mally/api/shared/rest/dtos/ApiResponseDTO.java b/apps/api/src/main/java/com/mally/api/shared/rest/dtos/ApiResponse.java similarity index 76% rename from apps/api/src/main/java/com/mally/api/shared/rest/dtos/ApiResponseDTO.java rename to apps/api/src/main/java/com/mally/api/shared/rest/dtos/ApiResponse.java index 5dfac56..b071c33 100644 --- a/apps/api/src/main/java/com/mally/api/shared/rest/dtos/ApiResponseDTO.java +++ b/apps/api/src/main/java/com/mally/api/shared/rest/dtos/ApiResponse.java @@ -9,13 +9,13 @@ @AllArgsConstructor @Getter @Setter -public class ApiResponseDTO { +public class ApiResponse { private String status; private String message; private Object data; private List errors; - public static ApiResponseDTO success(String message, Object data) { + public static ApiResponse success(String message, Object data) { return builder() .status("success") .message(message) @@ -23,7 +23,7 @@ public static ApiResponseDTO success(String message, Object data) { .build(); } - public static ApiResponseDTO error(String message, List errors) { + public static ApiResponse error(String message, List errors) { return builder() .status("error") .message(message) diff --git a/apps/api/src/main/java/com/mally/api/shared/schedulers/ExpiredRecordsPurger.java b/apps/api/src/main/java/com/mally/api/shared/schedulers/ExpiredRecordsPurger.java index 6ad09cc..d521c0b 100644 --- a/apps/api/src/main/java/com/mally/api/shared/schedulers/ExpiredRecordsPurger.java +++ b/apps/api/src/main/java/com/mally/api/shared/schedulers/ExpiredRecordsPurger.java @@ -2,7 +2,7 @@ import com.mally.api.pastebin.services.PastebinService; -import com.mally.api.urlshortener.services.UrlShortenerService; +import com.mally.api.urlshortener.infrastructure.services.UrlShortenerService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/apps/api/src/main/java/com/mally/api/shared/utils/PaginationUtils.java b/apps/api/src/main/java/com/mally/api/shared/utils/PaginationUtils.java index b7d6255..848d211 100644 --- a/apps/api/src/main/java/com/mally/api/shared/utils/PaginationUtils.java +++ b/apps/api/src/main/java/com/mally/api/shared/utils/PaginationUtils.java @@ -1,14 +1,10 @@ package com.mally.api.shared.utils; -import com.mally.api.pastebin.entities.Paste; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.*; import org.hibernate.query.sqm.tree.SqmCopyContext; import org.hibernate.query.sqm.tree.predicate.SqmPredicate; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.*; import java.util.ArrayList; import java.util.List; @@ -106,4 +102,8 @@ private static void doJoins(Set> joins, Join root, Sq doJoins(join.getJoins(), joined, copyContext); } } + + public static Pageable buildPageable(int pageNumber, int pageSize, String orderBy, String sortBy) { + return PageRequest.of(pageNumber, pageSize, Sort.by(orderBy.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy)); + } } diff --git a/apps/api/src/main/java/com/mally/api/stats/StatsController.java b/apps/api/src/main/java/com/mally/api/stats/StatsController.java index 7f3e60d..f7c4a55 100644 --- a/apps/api/src/main/java/com/mally/api/stats/StatsController.java +++ b/apps/api/src/main/java/com/mally/api/stats/StatsController.java @@ -2,7 +2,7 @@ import com.mally.api.auth.AuthenticationManager; import com.mally.api.auth.UserJwt; -import com.mally.api.shared.rest.dtos.ApiResponseDTO; +import com.mally.api.shared.rest.dtos.ApiResponse; import com.mally.api.stats.services.StatsService; import lombok.AllArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -17,9 +17,9 @@ public class StatsController { private final StatsService statsService; @GetMapping("/dashboard") - public ApiResponseDTO getDashboardStats() { + public ApiResponse getDashboardStats() { var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow(); - return ApiResponseDTO.success(null, statsService.getDashboardStats(userId)); + return ApiResponse.success(null, statsService.getDashboardStats(userId.value())); } } diff --git a/apps/api/src/main/java/com/mally/api/stats/services/StatsService.java b/apps/api/src/main/java/com/mally/api/stats/services/StatsService.java index a9c0329..56f2678 100644 --- a/apps/api/src/main/java/com/mally/api/stats/services/StatsService.java +++ b/apps/api/src/main/java/com/mally/api/stats/services/StatsService.java @@ -2,7 +2,7 @@ import com.mally.api.pastebin.services.PastebinService; import com.mally.api.stats.dtos.DashboardStatsDTO; -import com.mally.api.urlshortener.services.UrlShortenerService; +import com.mally.api.urlshortener.infrastructure.services.UrlShortenerService; import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/UrlShortenerController.java b/apps/api/src/main/java/com/mally/api/urlshortener/UrlShortenerController.java deleted file mode 100644 index afb7392..0000000 --- a/apps/api/src/main/java/com/mally/api/urlshortener/UrlShortenerController.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.mally.api.urlshortener; - -import com.mally.api.auth.AuthenticationManager; -import com.mally.api.auth.UserJwt; -import com.mally.api.shared.rest.dtos.ApiResponseDTO; -import com.mally.api.urlshortener.dtos.ShortenUrlDTO; -import com.mally.api.urlshortener.entities.Url; -import com.mally.api.urlshortener.services.UrlShortenerService; -import jakarta.validation.Valid; -import lombok.AllArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@RestController -@RequestMapping("/url-shortener") -@AllArgsConstructor -public class UrlShortenerController { - - private final UrlShortenerService urlShortenerService; - - @GetMapping() - public ResponseEntity> findAll( - @RequestParam(required = false) String search, - @RequestParam(defaultValue = "0") int pageNumber, - @RequestParam(defaultValue = "10") int pageSize, - @RequestParam(defaultValue = "createdAt") String sortBy, - @RequestParam(defaultValue = "DESC") String orderBy - ) { - var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow(); - var result = urlShortenerService.findAll( - userId, - search, - PageRequest.of(pageNumber, pageSize, Sort.by(orderBy.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy)) - ); - - return ResponseEntity.ok(result); - } - - @GetMapping("/redirect/{slug}") - public ResponseEntity redirect(@PathVariable String slug) { - final Optional longUrl = urlShortenerService.findLongUrl(slug); - - if (longUrl.isEmpty()) { - return ResponseEntity - .badRequest() - .body(ApiResponseDTO.error("URL not found or expired.", List.of())); - } - - Map data = Map.of("url", longUrl.get()); - - return ResponseEntity - .ok() - .body(ApiResponseDTO.success("Redirected successfully.", data)); - } - - @PostMapping("/shorten") - public ResponseEntity shorten(@Valid @RequestBody ShortenUrlDTO dto) { - var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null); - final Url url = urlShortenerService.save(dto, userId); - return ResponseEntity.ok().body(ApiResponseDTO.success("URL shortened successfully.", url)); - } - - @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable Long id) { - urlShortenerService.delete(id); - - return ResponseEntity.ok(ApiResponseDTO.success("URL deleted.", null)); - } - - @DeleteMapping("bulk") - public ResponseEntity bulkDelete(@RequestParam List id) { - urlShortenerService.deleteMany(id); - - return ResponseEntity.ok(ApiResponseDTO.success("URLs deleted.", null)); - } -} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/commands/GetUrlClickHistoryCommand.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/commands/GetUrlClickHistoryCommand.java new file mode 100644 index 0000000..9bf248e --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/commands/GetUrlClickHistoryCommand.java @@ -0,0 +1,7 @@ +package com.mally.api.urlshortener.application.commands; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; + +public record GetUrlClickHistoryCommand(ShortUrlId urlId, UserId userId) { +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/commands/ShortenUrlCommand.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/commands/ShortenUrlCommand.java new file mode 100644 index 0000000..c233e56 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/commands/ShortenUrlCommand.java @@ -0,0 +1,7 @@ +package com.mally.api.urlshortener.application.commands; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.urlshortener.domain.valueobjects.Url; + +public record ShortenUrlCommand(Url url, UserId userId) { +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/dtos/RedirectUrlResponse.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/dtos/RedirectUrlResponse.java new file mode 100644 index 0000000..99e152e --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/dtos/RedirectUrlResponse.java @@ -0,0 +1,4 @@ +package com.mally.api.urlshortener.application.dtos; + +public record RedirectUrlResponse(String url) { +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/dtos/ShortenUrlRequest.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/dtos/ShortenUrlRequest.java new file mode 100644 index 0000000..d22c4cf --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/dtos/ShortenUrlRequest.java @@ -0,0 +1,10 @@ +package com.mally.api.urlshortener.application.dtos; + +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.URL; + +public record ShortenUrlRequest ( + @URL + @NotBlank + String url +) {} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/events/UrlDeletedEvent.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/events/UrlDeletedEvent.java new file mode 100644 index 0000000..c1e51a5 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/events/UrlDeletedEvent.java @@ -0,0 +1,22 @@ +package com.mally.api.urlshortener.application.events; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UrlDeletedEvent implements Serializable { + + @JsonProperty + private ShortUrlId id; + +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/events/UrlRedirectedEvent.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/events/UrlRedirectedEvent.java new file mode 100644 index 0000000..e09d82e --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/events/UrlRedirectedEvent.java @@ -0,0 +1,22 @@ +package com.mally.api.urlshortener.application.events; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UrlRedirectedEvent implements Serializable { + + @JsonProperty + private ShortUrlId id; + +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/exceptions/UrlNotFoundOrExpiredException.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/exceptions/UrlNotFoundOrExpiredException.java new file mode 100644 index 0000000..fe1c29c --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/exceptions/UrlNotFoundOrExpiredException.java @@ -0,0 +1,10 @@ +package com.mally.api.urlshortener.application.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class UrlNotFoundOrExpiredException extends ResponseStatusException { + public UrlNotFoundOrExpiredException() { + super(HttpStatus.NOT_FOUND, "URL not found or expired"); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/DeleteManyUrlsUseCase.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/DeleteManyUrlsUseCase.java new file mode 100644 index 0000000..1956e39 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/DeleteManyUrlsUseCase.java @@ -0,0 +1,23 @@ +package com.mally.api.urlshortener.application.usecases; + +import com.mally.api.shared.abstractions.UseCase; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DeleteManyUrlsUseCase implements UseCase, Void> { + + private final UrlShortenerRepository urlShortenerRepository; + + @Override + public Void execute(List ids) { + urlShortenerRepository.deleteAllById(ids); + + return null; + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/DeleteUrlUseCase.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/DeleteUrlUseCase.java new file mode 100644 index 0000000..e63011b --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/DeleteUrlUseCase.java @@ -0,0 +1,27 @@ +package com.mally.api.urlshortener.application.usecases; + +import com.mally.api.shared.abstractions.UseCase; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.infrastructure.services.RabbitMQUrlShortenerPublisher; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DeleteUrlUseCase implements UseCase { + + private final UrlShortenerRepository urlShortenerRepository; + + private final RabbitMQUrlShortenerPublisher rabbitMQUrlShortenerPublisher; + + @Transactional + @Override + public Void execute(ShortUrlId id) { + urlShortenerRepository.deleteById(id); + rabbitMQUrlShortenerPublisher.notifyUrlDeletion(id); + + return null; + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/FindAllUrlsUseCase.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/FindAllUrlsUseCase.java new file mode 100644 index 0000000..ba00bdf --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/FindAllUrlsUseCase.java @@ -0,0 +1,41 @@ +package com.mally.api.urlshortener.application.usecases; + +import com.mally.api.shared.abstractions.UseCase; +import com.mally.api.shared.commands.FindAllCommand; +import com.mally.api.shared.utils.PaginationUtils; +import com.mally.api.urlshortener.domain.entities.ShortUrl; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.infrastructure.mappers.UrlShortenerMapper; +import com.mally.api.urlshortener.infrastructure.persistence.entities.ShortUrlEntity; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class FindAllUrlsUseCase implements UseCase> { + + private final UrlShortenerRepository urlShortenerRepository; + + private final EntityManager entityManager; + + @Override + public Page execute(FindAllCommand findAllCommand) { + var search = findAllCommand.search(); + var userId = findAllCommand.userId(); + var pageable = findAllCommand.pageable(); + + if (search != null && !search.isEmpty()) { + List searchFields = List.of("url", "slug"); + + var results = PaginationUtils.paginateSearch(entityManager, ShortUrlEntity.class, searchFields, search, userId.value(), pageable); + + return results.map(UrlShortenerMapper::toShortUrl); + } + + return urlShortenerRepository.findAllByUserId(userId, pageable); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/GetUrlClickHistoryUseCase.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/GetUrlClickHistoryUseCase.java new file mode 100644 index 0000000..44179c3 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/GetUrlClickHistoryUseCase.java @@ -0,0 +1,31 @@ +package com.mally.api.urlshortener.application.usecases; + +import com.mally.api.shared.abstractions.UseCase; +import com.mally.api.urlshortener.application.commands.GetUrlClickHistoryCommand; +import com.mally.api.urlshortener.application.exceptions.UrlNotFoundOrExpiredException; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.infrastructure.persistence.repositories.InfluxUrlShortenerRepository; +import com.mally.api.urlshortener.infrastructure.persistence.repositories.UrlClickHistoryPoint; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@AllArgsConstructor +public class GetUrlClickHistoryUseCase implements UseCase> { + private static final int DEFAULT_HISTORY_DAYS = 30; + + private final InfluxUrlShortenerRepository influxUrlShortenerRepository; + + private final UrlShortenerRepository urlShortenerRepository; + + @Override + public List execute(GetUrlClickHistoryCommand command) { + urlShortenerRepository + .findByIdAndUserId(command.urlId(), command.userId()) + .orElseThrow(UrlNotFoundOrExpiredException::new); + + return influxUrlShortenerRepository.getUrlClicksSince(DEFAULT_HISTORY_DAYS, command.urlId()); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/RedirectUrlUseCase.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/RedirectUrlUseCase.java new file mode 100644 index 0000000..bfa6032 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/RedirectUrlUseCase.java @@ -0,0 +1,35 @@ +package com.mally.api.urlshortener.application.usecases; + +import com.mally.api.shared.abstractions.UseCase; +import com.mally.api.urlshortener.application.exceptions.UrlNotFoundOrExpiredException; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import com.mally.api.urlshortener.domain.valueobjects.Url; +import com.mally.api.urlshortener.infrastructure.services.RabbitMQUrlShortenerPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.ZonedDateTime; + +@Service +@RequiredArgsConstructor +public class RedirectUrlUseCase implements UseCase { + + private final UrlShortenerRepository urlShortenerRepository; + + private final RabbitMQUrlShortenerPublisher rabbitMQUrlShortenerPublisher; + + @Override + public Url execute(Slug slug) { + var url = urlShortenerRepository.findBySlug(slug); + var urlExpired = url.isEmpty() || url.get().getExpiresAt().isBefore(ZonedDateTime.now()); + + if (urlExpired) { + throw new UrlNotFoundOrExpiredException(); + } + + rabbitMQUrlShortenerPublisher.notifyUrlRedirection(url.get().getId()); + + return url.get().getUrl(); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/ShortenUrlUseCase.java b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/ShortenUrlUseCase.java new file mode 100644 index 0000000..34d7c91 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/application/usecases/ShortenUrlUseCase.java @@ -0,0 +1,37 @@ +package com.mally.api.urlshortener.application.usecases; + +import com.mally.api.shared.abstractions.UseCase; +import com.mally.api.urlshortener.application.commands.ShortenUrlCommand; +import com.mally.api.urlshortener.domain.entities.ShortUrl; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import io.github.thibaultmeyer.cuid.CUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; + +@Component +@RequiredArgsConstructor +public class ShortenUrlUseCase implements UseCase { + static final Integer EXPIRES_IN_DAYS = 7; + + private final UrlShortenerRepository urlShortenerRepository; + + @Override + public ShortUrl execute(ShortenUrlCommand command) { + var slug = CUID.randomCUID2(8); + var now = ZonedDateTime.now(); + + return urlShortenerRepository.save( + ShortUrl.builder() + .url(command.url()) + .slug(new Slug(slug.toString())) + .custom(false) + .userId(command.userId()) + .createdAt(now) + .expiresAt(now.plusDays(EXPIRES_IN_DAYS)) + .build() + ); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/domain/entities/ShortUrl.java b/apps/api/src/main/java/com/mally/api/urlshortener/domain/entities/ShortUrl.java new file mode 100644 index 0000000..f6bffae --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/domain/entities/ShortUrl.java @@ -0,0 +1,217 @@ +package com.mally.api.urlshortener.domain.entities; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import com.mally.api.urlshortener.domain.valueobjects.Url; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; + +public class ShortUrl { + @NotNull + private ShortUrlId id; + + @NotNull + private Url url; + + @NotNull + private Slug slug; + + @NotNull + private Boolean custom; + + @Nullable + private UserId userId; + + @NotNull + private ZonedDateTime createdAt; + + @NotNull + private ZonedDateTime expiresAt; + + public ShortUrl(@NotNull ShortUrlId id, @NotNull Url url, @NotNull Slug slug, @NotNull Boolean custom, @Nullable UserId userId, @NotNull ZonedDateTime createdAt, @NotNull ZonedDateTime expiresAt) { + this.id = id; + this.url = url; + this.slug = slug; + this.custom = custom; + this.userId = userId; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + } + + public static ShortUrlBuilder builder() { + return new ShortUrlBuilder(); + } + + public @NotNull ShortUrlId getId() { + return this.id; + } + + public @NotNull Url getUrl() { + return this.url; + } + + public @NotNull Slug getSlug() { + return this.slug; + } + + public @NotNull Boolean getCustom() { + return this.custom; + } + + @Nullable + public UserId getUserId() { + return this.userId; + } + + public @NotNull ZonedDateTime getCreatedAt() { + return this.createdAt; + } + + public @NotNull ZonedDateTime getExpiresAt() { + return this.expiresAt; + } + + public void setId(@NotNull ShortUrlId id) { + this.id = id; + } + + public void setUrl(@NotNull Url url) { + this.url = url; + } + + public void setSlug(@NotNull Slug slug) { + this.slug = slug; + } + + public void setCustom(@NotNull Boolean custom) { + this.custom = custom; + } + + public void setUserId(@Nullable UserId userId) { + this.userId = userId; + } + + public void setCreatedAt(@NotNull ZonedDateTime createdAt) { + this.createdAt = createdAt; + } + + public void setExpiresAt(@NotNull ZonedDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean equals(final Object o) { + if (o == this) return true; + if (!(o instanceof ShortUrl)) return false; + final ShortUrl other = (ShortUrl) o; + if (!other.canEqual((Object) this)) return false; + final Object this$id = this.getId(); + final Object other$id = other.getId(); + if (this$id == null ? other$id != null : !this$id.equals(other$id)) return false; + final Object this$url = this.getUrl(); + final Object other$url = other.getUrl(); + if (this$url == null ? other$url != null : !this$url.equals(other$url)) return false; + final Object this$slug = this.getSlug(); + final Object other$slug = other.getSlug(); + if (this$slug == null ? other$slug != null : !this$slug.equals(other$slug)) return false; + final Object this$custom = this.getCustom(); + final Object other$custom = other.getCustom(); + if (this$custom == null ? other$custom != null : !this$custom.equals(other$custom)) return false; + final Object this$userId = this.getUserId(); + final Object other$userId = other.getUserId(); + if (this$userId == null ? other$userId != null : !this$userId.equals(other$userId)) return false; + final Object this$createdAt = this.getCreatedAt(); + final Object other$createdAt = other.getCreatedAt(); + if (this$createdAt == null ? other$createdAt != null : !this$createdAt.equals(other$createdAt)) return false; + final Object this$expiresAt = this.getExpiresAt(); + final Object other$expiresAt = other.getExpiresAt(); + if (this$expiresAt == null ? other$expiresAt != null : !this$expiresAt.equals(other$expiresAt)) return false; + return true; + } + + protected boolean canEqual(final Object other) { + return other instanceof ShortUrl; + } + + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $id = this.getId(); + result = result * PRIME + ($id == null ? 43 : $id.hashCode()); + final Object $url = this.getUrl(); + result = result * PRIME + ($url == null ? 43 : $url.hashCode()); + final Object $slug = this.getSlug(); + result = result * PRIME + ($slug == null ? 43 : $slug.hashCode()); + final Object $custom = this.getCustom(); + result = result * PRIME + ($custom == null ? 43 : $custom.hashCode()); + final Object $userId = this.getUserId(); + result = result * PRIME + ($userId == null ? 43 : $userId.hashCode()); + final Object $createdAt = this.getCreatedAt(); + result = result * PRIME + ($createdAt == null ? 43 : $createdAt.hashCode()); + final Object $expiresAt = this.getExpiresAt(); + result = result * PRIME + ($expiresAt == null ? 43 : $expiresAt.hashCode()); + return result; + } + + public String toString() { + return "ShortUrl(id=" + this.getId() + ", url=" + this.getUrl() + ", slug=" + this.getSlug() + ", custom=" + this.getCustom() + ", userId=" + this.getUserId() + ", createdAt=" + this.getCreatedAt() + ", expiresAt=" + this.getExpiresAt() + ")"; + } + + public static class ShortUrlBuilder { + private @NotNull ShortUrlId id; + private @NotNull Url url; + private @NotNull Slug slug; + private @NotNull Boolean custom; + private UserId userId; + private @NotNull ZonedDateTime createdAt; + private @NotNull ZonedDateTime expiresAt; + + ShortUrlBuilder() { + } + + public ShortUrlBuilder id(@NotNull ShortUrlId id) { + this.id = id; + return this; + } + + public ShortUrlBuilder url(@NotNull Url url) { + this.url = url; + return this; + } + + public ShortUrlBuilder slug(@NotNull Slug slug) { + this.slug = slug; + return this; + } + + public ShortUrlBuilder custom(@NotNull Boolean custom) { + this.custom = custom; + return this; + } + + public ShortUrlBuilder userId(UserId userId) { + this.userId = userId; + return this; + } + + public ShortUrlBuilder createdAt(@NotNull ZonedDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public ShortUrlBuilder expiresAt(@NotNull ZonedDateTime expiresAt) { + this.expiresAt = expiresAt; + return this; + } + + public ShortUrl build() { + return new ShortUrl(this.id, this.url, this.slug, this.custom, this.userId, this.createdAt, this.expiresAt); + } + + public String toString() { + return "ShortUrl.ShortUrlBuilder(id=" + this.id + ", url=" + this.url + ", slug=" + this.slug + ", custom=" + this.custom + ", userId=" + this.userId + ", createdAt=" + this.createdAt + ", expiresAt=" + this.expiresAt + ")"; + } + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/domain/repositories/UrlShortenerRepository.java b/apps/api/src/main/java/com/mally/api/urlshortener/domain/repositories/UrlShortenerRepository.java new file mode 100644 index 0000000..86f01c6 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/domain/repositories/UrlShortenerRepository.java @@ -0,0 +1,30 @@ +package com.mally.api.urlshortener.domain.repositories; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.urlshortener.domain.entities.ShortUrl; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +public interface UrlShortenerRepository { + ShortUrl save(ShortUrl shortUrl); + + Page findAllByUserId(UserId userId, Pageable pageable); + + Optional findByIdAndUserId(ShortUrlId id, UserId userId); + + Optional findBySlug(Slug slug); + + void deleteExpiredURLs(ZonedDateTime now); + + Long countByUserId(UserId userId); + + void deleteAllById(List ids); + + void deleteById(ShortUrlId id); +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/ShortUrlId.java b/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/ShortUrlId.java new file mode 100644 index 0000000..3354477 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/ShortUrlId.java @@ -0,0 +1,14 @@ +package com.mally.api.urlshortener.domain.valueobjects; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record ShortUrlId(Long value) { + public ShortUrlId { + assert value != null; + } + + @JsonValue + public Long getValue() { + return value; + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/Slug.java b/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/Slug.java new file mode 100644 index 0000000..a56dc1c --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/Slug.java @@ -0,0 +1,15 @@ +package com.mally.api.urlshortener.domain.valueobjects; + +import com.fasterxml.jackson.annotation.JsonValue; + +public record Slug(String value) { + + public Slug { + assert value != null; + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/Url.java b/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/Url.java new file mode 100644 index 0000000..9e63209 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/domain/valueobjects/Url.java @@ -0,0 +1,17 @@ +package com.mally.api.urlshortener.domain.valueobjects; + +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.regex.Pattern; + +public record Url(String value) { + public Url { + assert value != null; + assert Pattern.matches("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)", value); + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/dtos/SearchUrlsDTO.java b/apps/api/src/main/java/com/mally/api/urlshortener/dtos/SearchUrlsDTO.java deleted file mode 100644 index 322fefd..0000000 --- a/apps/api/src/main/java/com/mally/api/urlshortener/dtos/SearchUrlsDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.mally.api.urlshortener.dtos; - -import com.mally.api.shared.rest.dtos.PaginationDTO; -import jakarta.annotation.Nullable; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class SearchUrlsDTO extends PaginationDTO { - @Nullable - String url; - - @Nullable - String slug; - - @Nullable - Boolean custom; - - @Nullable - String createdAt; - - @Nullable - String expiresAt; -} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/dtos/ShortenUrlDTO.java b/apps/api/src/main/java/com/mally/api/urlshortener/dtos/ShortenUrlDTO.java deleted file mode 100644 index 905f129..0000000 --- a/apps/api/src/main/java/com/mally/api/urlshortener/dtos/ShortenUrlDTO.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.mally.api.urlshortener.dtos; - -import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.validator.constraints.URL; - -@Getter -@AllArgsConstructor -@NoArgsConstructor -public class ShortenUrlDTO { - @URL - @NotBlank - private String url; -} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/configuration/RabbitMQUrlShortenerQueues.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/configuration/RabbitMQUrlShortenerQueues.java new file mode 100644 index 0000000..8285c25 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/configuration/RabbitMQUrlShortenerQueues.java @@ -0,0 +1,20 @@ +package com.mally.api.urlshortener.infrastructure.configuration; + +import org.springframework.amqp.core.Queue; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class RabbitMQUrlShortenerQueues { + + @Bean + public Queue urlRedirectedQueue() { + return new Queue(UrlShortenerQueues.URL_REDIRECTED_QUEUE, true); + } + + @Bean + public Queue urlDeletedQueue() { + return new Queue(UrlShortenerQueues.URL_DELETED_QUEUE, true); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/configuration/UrlShortenerQueues.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/configuration/UrlShortenerQueues.java new file mode 100644 index 0000000..424e8a2 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/configuration/UrlShortenerQueues.java @@ -0,0 +1,6 @@ +package com.mally.api.urlshortener.infrastructure.configuration; + +public final class UrlShortenerQueues { + public static final String URL_REDIRECTED_QUEUE = "url.redirected"; + public static final String URL_DELETED_QUEUE = "url.deleted"; +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/mappers/UrlShortenerMapper.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/mappers/UrlShortenerMapper.java new file mode 100644 index 0000000..96abc0f --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/mappers/UrlShortenerMapper.java @@ -0,0 +1,34 @@ +package com.mally.api.urlshortener.infrastructure.mappers; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.urlshortener.domain.entities.ShortUrl; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import com.mally.api.urlshortener.domain.valueobjects.Url; +import com.mally.api.urlshortener.infrastructure.persistence.entities.ShortUrlEntity; + +public class UrlShortenerMapper { + public static ShortUrl toShortUrl(ShortUrlEntity entity) { + return new ShortUrl( + new ShortUrlId(entity.getId()), + new Url(entity.getLongUrl()), + new Slug(entity.getSlug()), + entity.getCustom(), + new UserId(entity.getUserId()), + entity.getCreatedAt(), + entity.getExpiresAt() + ); + } + + public static ShortUrlEntity toEntity(ShortUrl shortUrl) { + return ShortUrlEntity.builder() + .id(shortUrl.getId() == null ? null : shortUrl.getId().value()) + .longUrl(shortUrl.getUrl().value()) + .slug(shortUrl.getSlug().value()) + .custom(shortUrl.getCustom()) + .userId(shortUrl.getUserId() == null ? null : shortUrl.getUserId().value()) + .createdAt(shortUrl.getCreatedAt()) + .expiresAt(shortUrl.getExpiresAt()) + .build(); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/entities/Url.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/entities/ShortUrlEntity.java similarity index 79% rename from apps/api/src/main/java/com/mally/api/urlshortener/entities/Url.java rename to apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/entities/ShortUrlEntity.java index e2c5898..dd7201e 100644 --- a/apps/api/src/main/java/com/mally/api/urlshortener/entities/Url.java +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/entities/ShortUrlEntity.java @@ -1,4 +1,4 @@ -package com.mally.api.urlshortener.entities; +package com.mally.api.urlshortener.infrastructure.persistence.entities; import jakarta.annotation.Nullable; import jakarta.persistence.*; @@ -9,19 +9,19 @@ import java.time.ZonedDateTime; @Builder -@Entity +@Entity(name = "short_url") @NoArgsConstructor @AllArgsConstructor @Getter @Setter -public class Url { +public class ShortUrlEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column + @Column(name = "long_url") @NotNull - private String url; + private String longUrl; @Column(unique = true) @NotNull diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/entities/UrlClickEntity.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/entities/UrlClickEntity.java new file mode 100644 index 0000000..11db1bd --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/entities/UrlClickEntity.java @@ -0,0 +1,29 @@ +package com.mally.api.urlshortener.infrastructure.persistence.entities; + +import com.influxdb.annotations.Column; +import com.influxdb.annotations.Measurement; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Measurement(name = "url-click") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UrlClickEntity { + + public static String BUCKET_NAME = "url-clicks"; + + @Column(tag = true) + private Long id; + + @Column + Boolean value; + + @Column(timestamp = true) + private Instant time; +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/InfluxUrlShortenerRepository.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/InfluxUrlShortenerRepository.java new file mode 100644 index 0000000..1560825 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/InfluxUrlShortenerRepository.java @@ -0,0 +1,46 @@ +package com.mally.api.urlshortener.infrastructure.persistence.repositories; + +import com.mally.api.shared.repositories.InfluxRepository; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.infrastructure.persistence.entities.UrlClickEntity; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +@AllArgsConstructor +public class InfluxUrlShortenerRepository { + + private final InfluxRepository influxRepository; + + public List getUrlClicksSince(Integer days, ShortUrlId urlId) { + var records = influxRepository.query( + String.format( + "from(bucket:\"url-clicks\") " + + "|> range(start: -%sd)" + + "|> filter(fn: (r) => r.id == \"%d\")" + , days, urlId.value()) + ); + + return records.stream() + .map(r -> new UrlClickHistoryPoint((Instant) r.getValueByKey("_time"))) + .toList(); + } + + public void saveUrlClick(ShortUrlId id) { + UrlClickEntity urlClick = UrlClickEntity.builder() + .id(id.value()) + .value(Boolean.TRUE) + .time(Instant.now()) + .build(); + + influxRepository.writeMeasurement(urlClick, UrlClickEntity.BUCKET_NAME); + } + + public void deleteUrlClicks(ShortUrlId id) { + influxRepository.deleteMeasurement(id.value(), UrlClickEntity.BUCKET_NAME); + } +} + diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/JpaUrlShortenerRepository.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/JpaUrlShortenerRepository.java new file mode 100644 index 0000000..c50233d --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/JpaUrlShortenerRepository.java @@ -0,0 +1,67 @@ +package com.mally.api.urlshortener.infrastructure.persistence.repositories; + +import com.mally.api.auth.domain.valueobjects.UserId; +import com.mally.api.urlshortener.domain.entities.ShortUrl; +import com.mally.api.urlshortener.domain.repositories.UrlShortenerRepository; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import com.mally.api.urlshortener.infrastructure.mappers.UrlShortenerMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class JpaUrlShortenerRepository implements UrlShortenerRepository { + + private final SpringDataUrlShortenerRepository springDataUrlShortenerRepository; + + @Override + public ShortUrl save(ShortUrl shortUrl) { + var result = springDataUrlShortenerRepository.save(UrlShortenerMapper.toEntity(shortUrl)); + return UrlShortenerMapper.toShortUrl(result); + } + + @Override + public Page findAllByUserId(UserId userId, Pageable pageable) { + var result = springDataUrlShortenerRepository.findAllByUserId(userId.value(), pageable); + return result.map(UrlShortenerMapper::toShortUrl) ; + } + + @Override + public Optional findByIdAndUserId(ShortUrlId id, UserId userId) { + var result = springDataUrlShortenerRepository.findByIdAndUserId(id.value(), userId.value()); + return result.map(UrlShortenerMapper::toShortUrl); + } + + @Override + public Optional findBySlug(Slug slug) { + var result = springDataUrlShortenerRepository.findBySlug(slug.value()); + return result.map(UrlShortenerMapper::toShortUrl); + } + + @Override + public void deleteExpiredURLs(ZonedDateTime now) { + springDataUrlShortenerRepository.deleteExpiredURLs(now); + } + + @Override + public Long countByUserId(UserId userId) { + return springDataUrlShortenerRepository.countByUserId(userId.value()); + } + + @Override + public void deleteAllById(List ids) { + springDataUrlShortenerRepository.deleteAllById(ids.stream().map(ShortUrlId::value).toList()); + } + + @Override + public void deleteById(ShortUrlId id) { + springDataUrlShortenerRepository.deleteById(id.value()); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/repositories/UrlShortenerRepository.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/SpringDataUrlShortenerRepository.java similarity index 54% rename from apps/api/src/main/java/com/mally/api/urlshortener/repositories/UrlShortenerRepository.java rename to apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/SpringDataUrlShortenerRepository.java index ea5bcdb..3289f50 100644 --- a/apps/api/src/main/java/com/mally/api/urlshortener/repositories/UrlShortenerRepository.java +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/SpringDataUrlShortenerRepository.java @@ -1,6 +1,6 @@ -package com.mally.api.urlshortener.repositories; +package com.mally.api.urlshortener.infrastructure.persistence.repositories; -import com.mally.api.urlshortener.entities.Url; +import com.mally.api.urlshortener.infrastructure.persistence.entities.ShortUrlEntity; import jakarta.transaction.Transactional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -14,15 +14,17 @@ import java.util.Optional; @Repository -public interface UrlShortenerRepository extends CrudRepository { +public interface SpringDataUrlShortenerRepository extends CrudRepository { - Page findAllByUserId(String userId, Pageable pageable); + Page findAllByUserId(String userId, Pageable pageable); - Optional findBySlug(String slug); + Optional findByIdAndUserId(Long id, String userId); + + Optional findBySlug(String slug); @Transactional @Modifying - @Query("DELETE FROM Url u WHERE u.expiresAt < :now") + @Query("DELETE FROM short_url u WHERE u.expiresAt < :now") void deleteExpiredURLs(@Param("now") ZonedDateTime now); Long countByUserId(String userId); diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/UrlClickHistoryPoint.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/UrlClickHistoryPoint.java new file mode 100644 index 0000000..526929c --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/persistence/repositories/UrlClickHistoryPoint.java @@ -0,0 +1,7 @@ +package com.mally.api.urlshortener.infrastructure.persistence.repositories; + +import java.time.Instant; + +public record UrlClickHistoryPoint(Instant timestamp) { + +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/RabbitMQUrlShortenerListener.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/RabbitMQUrlShortenerListener.java new file mode 100644 index 0000000..6d6c8eb --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/RabbitMQUrlShortenerListener.java @@ -0,0 +1,31 @@ +package com.mally.api.urlshortener.infrastructure.services; + +import com.mally.api.urlshortener.application.events.UrlDeletedEvent; +import com.mally.api.urlshortener.application.events.UrlRedirectedEvent; +import com.mally.api.urlshortener.infrastructure.configuration.UrlShortenerQueues; +import com.mally.api.urlshortener.infrastructure.persistence.repositories.InfluxUrlShortenerRepository; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +@Slf4j +public class RabbitMQUrlShortenerListener { + private final InfluxUrlShortenerRepository influxUrlShortenerRepository; + + @RabbitListener(queues = UrlShortenerQueues.URL_REDIRECTED_QUEUE) + public void processUrlRedirectedEvent(UrlRedirectedEvent urlRedirectedEvent) { + log.info("Processing URL click for id = {}", urlRedirectedEvent.getId().value()); + + influxUrlShortenerRepository.saveUrlClick(urlRedirectedEvent.getId()); + } + + @RabbitListener(queues = UrlShortenerQueues.URL_DELETED_QUEUE) + public void processUrlDeletionEvent(UrlDeletedEvent urlDeletedEvent) { + log.info("Processing URL deletion event for id = {}", urlDeletedEvent.getId().value()); + + influxUrlShortenerRepository.deleteUrlClicks(urlDeletedEvent.getId()); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/RabbitMQUrlShortenerPublisher.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/RabbitMQUrlShortenerPublisher.java new file mode 100644 index 0000000..7b86a14 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/RabbitMQUrlShortenerPublisher.java @@ -0,0 +1,30 @@ +package com.mally.api.urlshortener.infrastructure.services; + +import com.mally.api.urlshortener.application.events.UrlDeletedEvent; +import com.mally.api.urlshortener.application.events.UrlRedirectedEvent; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.infrastructure.configuration.UrlShortenerQueues; +import lombok.AllArgsConstructor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +public class RabbitMQUrlShortenerPublisher { + + private final RabbitTemplate rabbitTemplate; + + public void notifyUrlRedirection(ShortUrlId id) { + rabbitTemplate.convertAndSend( + UrlShortenerQueues.URL_REDIRECTED_QUEUE, + UrlRedirectedEvent.builder().id(id).build() + ); + } + + public void notifyUrlDeletion(ShortUrlId id) { + rabbitTemplate.convertAndSend( + UrlShortenerQueues.URL_DELETED_QUEUE, + UrlDeletedEvent.builder().id(id).build() + ); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/UrlShortenerService.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/UrlShortenerService.java new file mode 100644 index 0000000..1020def --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/services/UrlShortenerService.java @@ -0,0 +1,21 @@ +package com.mally.api.urlshortener.infrastructure.services; + +import com.mally.api.urlshortener.infrastructure.persistence.repositories.SpringDataUrlShortenerRepository; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.ZonedDateTime; + +@Service +@AllArgsConstructor +public class UrlShortenerService { + private final SpringDataUrlShortenerRepository springDataUrlShortenerRepository; + + public void deleteExpiredURLs() { + springDataUrlShortenerRepository.deleteExpiredURLs(ZonedDateTime.now()); + } + + public Long getStats(String userId) { + return springDataUrlShortenerRepository.countByUserId(userId); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/web/UrlShortenerController.java b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/web/UrlShortenerController.java new file mode 100644 index 0000000..307948e --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/infrastructure/web/UrlShortenerController.java @@ -0,0 +1,93 @@ +package com.mally.api.urlshortener.infrastructure.web; + +import com.mally.api.auth.AuthenticationManager; +import com.mally.api.auth.UserJwt; +import com.mally.api.shared.commands.FindAllCommand; +import com.mally.api.shared.rest.dtos.ApiResponse; +import com.mally.api.urlshortener.application.commands.GetUrlClickHistoryCommand; +import com.mally.api.urlshortener.application.commands.ShortenUrlCommand; +import com.mally.api.urlshortener.application.dtos.RedirectUrlResponse; +import com.mally.api.urlshortener.application.dtos.ShortenUrlRequest; +import com.mally.api.urlshortener.application.usecases.*; +import com.mally.api.urlshortener.domain.entities.ShortUrl; +import com.mally.api.urlshortener.domain.valueobjects.ShortUrlId; +import com.mally.api.urlshortener.domain.valueobjects.Slug; +import com.mally.api.urlshortener.domain.valueobjects.Url; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/url-shortener") +@AllArgsConstructor +public class UrlShortenerController { + + private final FindAllUrlsUseCase findAllUrlsUseCase; + private final RedirectUrlUseCase redirectUrlUseCase; + private final GetUrlClickHistoryUseCase getUrlClickHistoryUseCase; + private final DeleteUrlUseCase deleteUrlUseCase; + private final ShortenUrlUseCase shortenUrlUseCase; + private final DeleteManyUrlsUseCase deleteManyUrlsUseCase; + + @GetMapping() + public Page findAll( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "0") int pageNumber, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(defaultValue = "createdAt") String sortBy, + @RequestParam(defaultValue = "DESC") String orderBy + ) { + var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow(); + + return findAllUrlsUseCase.execute(new FindAllCommand( + userId, search, pageNumber, pageSize, orderBy, sortBy + )); + } + + @GetMapping("/redirect/{slug}") + public ApiResponse redirect(@PathVariable String slug) { + var longUrl = redirectUrlUseCase.execute(new Slug(slug)); + + return ApiResponse.success("Redirected successfully.", new RedirectUrlResponse(longUrl.value())); + } + + @GetMapping("/{id}/history") + public ApiResponse getUrlClickHistory(@PathVariable Long id) { + var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null); + + var history = getUrlClickHistoryUseCase.execute( + new GetUrlClickHistoryCommand(new ShortUrlId(id), userId) + ); + + return ApiResponse.success("History obtained successfully.", history); + } + + @PostMapping("/shorten") + public ResponseEntity shorten(@Valid @RequestBody ShortenUrlRequest dto) { + var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null); + + var url = shortenUrlUseCase.execute( + new ShortenUrlCommand(new Url(dto.url()), userId) + ); + + return ResponseEntity.ok().body(ApiResponse.success("URL shortened successfully.", url)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + deleteUrlUseCase.execute(new ShortUrlId(id)); + + return ResponseEntity.ok(ApiResponse.success("URL deleted.", null)); + } + + @DeleteMapping("bulk") + public ResponseEntity bulkDelete(@RequestParam List id) { + deleteManyUrlsUseCase.execute(id.stream().map(ShortUrlId::new).toList()); + + return ResponseEntity.ok(ApiResponse.success("URLs deleted.", null)); + } +} diff --git a/apps/api/src/main/java/com/mally/api/urlshortener/services/UrlShortenerService.java b/apps/api/src/main/java/com/mally/api/urlshortener/services/UrlShortenerService.java deleted file mode 100644 index 267b407..0000000 --- a/apps/api/src/main/java/com/mally/api/urlshortener/services/UrlShortenerService.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.mally.api.urlshortener.services; - -import com.mally.api.shared.utils.PaginationUtils; -import com.mally.api.urlshortener.dtos.ShortenUrlDTO; -import com.mally.api.urlshortener.entities.Url; -import com.mally.api.urlshortener.repositories.UrlShortenerRepository; -import io.github.thibaultmeyer.cuid.CUID; -import jakarta.annotation.Nullable; -import jakarta.persistence.EntityManager; -import lombok.AllArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - - -import java.time.ZonedDateTime; -import java.util.List; -import java.util.Optional; - -@Service -@AllArgsConstructor -public class UrlShortenerService { - static final Integer EXPIRES_IN_DAYS = 7; - - private final UrlShortenerRepository urlShortenerRepository; - - private final EntityManager entityManager; - - public Page findAll(String userId, @Nullable String search, Pageable pageable) { - if (search != null && !search.isEmpty()) { - List searchFields = List.of("url", "slug"); - - return PaginationUtils.paginateSearch(entityManager, Url.class, searchFields, search, userId, pageable); - } - - return urlShortenerRepository.findAllByUserId(userId, pageable); - } - - public Optional findLongUrl(String slug) { - final Optional url = urlShortenerRepository.findBySlug(slug); - final boolean urlExpired = url.isEmpty() || url.get().getExpiresAt().isBefore(ZonedDateTime.now()); - - if (urlExpired) { - return Optional.empty(); - } - - return url.map(Url::getUrl); - } - - public Url save(ShortenUrlDTO dto, String userId) { - final CUID slug = CUID.randomCUID2(8); - final ZonedDateTime createdAt = ZonedDateTime.now(); - - final Url url = Url.builder() - .url(dto.getUrl()) - .slug(slug.toString()) - .custom(false) - .userId(userId) - .createdAt(createdAt) - .expiresAt(createdAt.plusDays(EXPIRES_IN_DAYS)) - .build(); - - return urlShortenerRepository.save(url); - } - - public void deleteExpiredURLs() { - urlShortenerRepository.deleteExpiredURLs(ZonedDateTime.now()); - } - - public void delete(Long id) { - urlShortenerRepository.deleteById(id); - } - - public void deleteMany(List ids) { - urlShortenerRepository.deleteAllById(ids); - } - - public Long getStats(String userId) { - return urlShortenerRepository.countByUserId(userId); - } -} diff --git a/apps/api/src/main/resources/application.prod.yml b/apps/api/src/main/resources/application.prod.yml index a586b57..afd9e76 100644 --- a/apps/api/src/main/resources/application.prod.yml +++ b/apps/api/src/main/resources/application.prod.yml @@ -21,6 +21,23 @@ spring: jwt: issuer-uri: ${KEYCLOAK_ISSUER_URL} + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:mally} + password: ${RABBITMQ_PASSWORD:mally} + +influx: + url: ${INFLUX_URL:http://localhost:8086} + username: ${INFLUX_USER:mally} + password: ${INFLUX_PASSWORD:mally123} + token: ${INFLUX_TOKEN:mally123} + org: ${INFLUX_ORG:mally} + buckets: url-clicks + +logging: + config: classpath:logback-prod.xml + management: endpoints: web: diff --git a/apps/api/src/main/resources/application.testing.yml b/apps/api/src/main/resources/application.test.yml similarity index 61% rename from apps/api/src/main/resources/application.testing.yml rename to apps/api/src/main/resources/application.test.yml index 1c3541e..9132a87 100644 --- a/apps/api/src/main/resources/application.testing.yml +++ b/apps/api/src/main/resources/application.test.yml @@ -21,6 +21,23 @@ spring: jwt: issuer-uri: "http://keycloak:9090/realms/mally" + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:mally} + password: ${RABBITMQ_PASSWORD:mally} + +influx: + url: ${INFLUX_URL:http://localhost:8086} + username: ${INFLUX_USER:mally} + password: ${INFLUX_PASSWORD:mally123} + token: ${INFLUX_TOKEN:mally123} + org: ${INFLUX_ORG:mally} + buckets: url-clicks + +logging: + config: classpath:logback-test.xml + bucket4j: enabled: true filter-config-caching-enabled: true diff --git a/apps/api/src/main/resources/application.yml b/apps/api/src/main/resources/application.yml index 30be925..e6a461f 100644 --- a/apps/api/src/main/resources/application.yml +++ b/apps/api/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: datasource: - url: jdbc:postgresql://localhost:5432/mally - username: postgres - password: postgres + url: jdbc:${POSTGRES_URL:postgresql://localhost:5432/mally} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} driverClassName: org.postgresql.Driver jpa: @@ -19,7 +19,24 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: "http://localhost:9090/realms/mally" + issuer-uri: ${KEYCLOAK_ISSUER_URL:http://localhost:9090/realms/mally} + + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:mally} + password: ${RABBITMQ_PASSWORD:mally} + +influx: + url: ${INFLUX_URL:http://localhost:8086} + username: ${INFLUX_USER:mally} + password: ${INFLUX_PASSWORD:mally123} + token: ${INFLUX_TOKEN:mally123} + org: ${INFLUX_ORG:mally} + buckets: url-clicks + +logging: + config: classpath:logback-test.xml bucket4j: enabled: true @@ -28,4 +45,4 @@ bucket4j: mally: client: - url: 'http://localhost:4200' + url: ${FRONTEND_URL:http://localhost:4200} diff --git a/apps/api/src/main/resources/db/changelog/db.changelog-master.yaml b/apps/api/src/main/resources/db/changelog/db.changelog-master.yaml index 604bbaf..8e8a7c1 100644 --- a/apps/api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/apps/api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -8,4 +8,6 @@ databaseChangeLog: - include: file: 'db/changelog/scripts/v4-add-user-id-to-url-and-paste.yaml' - include: - file: 'db/changelog/scripts/v5-add-title-to-paste.yaml' \ No newline at end of file + file: 'db/changelog/scripts/v5-add-title-to-paste.yaml' + - include: + file: 'db/changelog/scripts/v6-rename-table-and-columns-url.yaml' \ No newline at end of file diff --git a/apps/api/src/main/resources/db/changelog/scripts/v6-rename-table-and-columns-url.yaml b/apps/api/src/main/resources/db/changelog/scripts/v6-rename-table-and-columns-url.yaml new file mode 100644 index 0000000..1987800 --- /dev/null +++ b/apps/api/src/main/resources/db/changelog/scripts/v6-rename-table-and-columns-url.yaml @@ -0,0 +1,16 @@ +databaseChangeLog: + - changeSet: + id: 6-1 + author: Neuman F. + changes: + - renameTable: + oldTableName: url + newTableName: short_url + - changeSet: + id: 6-2 + author: Neuman F. + changes: + - renameColumn: + tableName: short_url + oldColumnName: url + newColumnName: long_url \ No newline at end of file diff --git a/apps/api/src/main/resources/logback-spring.xml b/apps/api/src/main/resources/logback-prod.xml similarity index 97% rename from apps/api/src/main/resources/logback-spring.xml rename to apps/api/src/main/resources/logback-prod.xml index 38fbc10..6f380c3 100644 --- a/apps/api/src/main/resources/logback-spring.xml +++ b/apps/api/src/main/resources/logback-prod.xml @@ -1,5 +1,5 @@ - + diff --git a/apps/api/src/main/resources/logback-test.xml b/apps/api/src/main/resources/logback-test.xml new file mode 100644 index 0000000..0f9d842 --- /dev/null +++ b/apps/api/src/main/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + + + + + + ${CONSOLE_LOG_PATTERN} + utf8 + + + + + + + \ No newline at end of file diff --git a/apps/api/testing.Dockerfile b/apps/api/test.Dockerfile similarity index 93% rename from apps/api/testing.Dockerfile rename to apps/api/test.Dockerfile index 78bca79..c1393c5 100644 --- a/apps/api/testing.Dockerfile +++ b/apps/api/test.Dockerfile @@ -14,7 +14,7 @@ FROM eclipse-temurin:21-jre-alpine ENV ARTIFACT_NAME=api-0.0.1-SNAPSHOT.jar ENV APP_HOME=/app -ENV CONFIGURATION_FILE=application.testing.yml +ENV CONFIGURATION_FILE=application.test.yml RUN apk add curl diff --git a/apps/ui/src/app/app.component.ts b/apps/ui/src/app/app.component.ts index fcf0a1b..f6374e2 100644 --- a/apps/ui/src/app/app.component.ts +++ b/apps/ui/src/app/app.component.ts @@ -2,7 +2,6 @@ import { AfterViewInit, Component } from '@angular/core'; import { fadeInAnimation } from './shared/animations/fadeIn'; import { fadeOutAnimation } from './shared/animations/fadeOut'; import { RouterOutlet } from '@angular/router'; -import { NgIf } from '@angular/common'; import { ToastModule } from 'primeng/toast'; @Component({ @@ -11,7 +10,7 @@ import { ToastModule } from 'primeng/toast'; styleUrl: './app.component.scss', animations: [fadeInAnimation(1), fadeOutAnimation(0.25)], standalone: true, - imports: [ToastModule, NgIf, RouterOutlet], + imports: [ToastModule, RouterOutlet], }) export class AppComponent implements AfterViewInit { title = 'ui'; diff --git a/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.ts b/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.ts index 6e6c25d..5a4430b 100644 --- a/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.ts +++ b/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.ts @@ -6,14 +6,13 @@ import { import { ToastService } from '../../../shared/services/toast/toast.service'; import { SkeletonModule } from 'primeng/skeleton'; import { StatsCardComponent } from './stats-card/stats-card.component'; -import { NgIf } from '@angular/common'; @Component({ selector: 'app-dashboard-index', templateUrl: './dashboard-index.component.html', styleUrl: './dashboard-index.component.scss', standalone: true, - imports: [NgIf, StatsCardComponent, SkeletonModule], + imports: [StatsCardComponent, SkeletonModule], providers: [StatsService], }) export class DashboardIndexComponent implements OnInit { diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html index 40d8f6d..371e348 100644 --- a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html +++ b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html @@ -82,7 +82,7 @@ {{ window.location.hostname + '/p/' + paste.slug }} @@ -113,7 +113,14 @@ [model]="optionsItems" [popup]="true" appendTo="body" - /> + > + + + + {{ item.label }} + + + {{ window.location.hostname + '/s/' + url.slug }} - {{ url.url }} + {{ url.url }} @if (url.custom) { @@ -114,7 +114,14 @@ [model]="optionsItems" [popup]="true" appendTo="body" - /> + > + + + + {{ item.label }} + + + + + + @if (loadingHistory()) { + + } @else { + @if (history()) { + + } @else { +

No data found

+ } + } +
diff --git a/apps/ui/src/app/dashboard/pages/urls/urls.component.ts b/apps/ui/src/app/dashboard/pages/urls/urls.component.ts index dbf8fad..b73cfe7 100644 --- a/apps/ui/src/app/dashboard/pages/urls/urls.component.ts +++ b/apps/ui/src/app/dashboard/pages/urls/urls.component.ts @@ -1,6 +1,14 @@ -import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + OnInit, + signal, + TemplateRef, + ViewChild, +} from '@angular/core'; import { PaginationParams, + UrlClickHistory, UrlShortenerService, } from '../../../url-shortener/services/url-shortener.service'; import { Url } from '../../../shared/interfaces/url'; @@ -17,8 +25,7 @@ import { Page } from '../../../shared/interfaces/http'; import { TableLazyLoadEvent, TableModule } from 'primeng/table'; import { DateUtils } from '../../../shared/utils/date'; import { TooltipModule } from 'primeng/tooltip'; -import { DatePipe, NgIf } from '@angular/common'; -import { ButtonDirective, ButtonIcon, ButtonLabel } from 'primeng/button'; +import { DatePipe } from '@angular/common'; import { InputTextModule } from 'primeng/inputtext'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ToastModule } from 'primeng/toast'; @@ -26,6 +33,11 @@ import { TablerIconComponent } from 'angular-tabler-icons'; import { ButtonComponent } from '../../../shared/components/button/button.component'; import { IconField } from 'primeng/iconfield'; import { InputIcon } from 'primeng/inputicon'; +import { Dialog } from 'primeng/dialog'; +import { UIChart } from 'primeng/chart'; +import dayjs from 'dayjs'; +import { ChartData } from 'chart.js'; +import { finalize } from 'rxjs'; @Component({ selector: 'app-urls', @@ -38,30 +50,65 @@ import { InputIcon } from 'primeng/inputicon'; TableModule, PrimeTemplate, InputTextModule, - NgIf, TooltipModule, MenuModule, DatePipe, TablerIconComponent, - ButtonDirective, - ButtonIcon, - ButtonLabel, ButtonComponent, IconField, InputIcon, + Dialog, + UIChart, ], providers: [UrlShortenerService, ConfirmationService], }) -export class UrlsComponent { +export class UrlsComponent implements OnInit { @ViewChild('pageHeader', { static: true }) pageHeaderTemplate!: TemplateRef; + private readonly HISTORY_DATE_FORMAT = 'YYYY-MM-DD'; + + historyVisible = signal(false); + loadingHistory = signal(false); + history = signal(null); + historyChartOptions = signal({}); + data?: Page; selectedUrls: Url[] = []; optionsItems: MenuItem[] = [ { label: 'Options', items: [ + { + label: 'Click history', + icon: 'graph', + command: (event: MenuItemCommandEvent) => { + const id = event.item?.['data']['id']; + + if (!id) return; + + const url = this.data?.content.find((d) => d.id === id); + + if (!url) return; + + this.loadingHistory.set(true); + this.urlShortenerService + .getHistory(id) + .pipe( + finalize(() => this.loadingHistory.set(false)), + ) + .subscribe({ + next: (history) => + this.showHistory(history, url), + error: (error: HttpErrorResponse) => { + this.toastService.error( + error.error.message ?? + 'Something went wrong', + ); + }, + }); + }, + }, { label: 'Delete', icon: 'trash', @@ -93,8 +140,57 @@ export class UrlsComponent { private readonly urlShortenerService: UrlShortenerService, private readonly confirmationService: ConfirmationService, private readonly toastService: ToastService, + private readonly changeDetector: ChangeDetectorRef, ) {} + ngOnInit() { + this.initChart(); + } + + initChart() { + const documentStyle = getComputedStyle(document.documentElement); + const textColor = documentStyle.getPropertyValue('--p-text-color'); + const textColorSecondary = documentStyle.getPropertyValue( + '--p-text-muted-color', + ); + const surfaceBorder = documentStyle.getPropertyValue( + '--p-content-border-color', + ); + + this.historyChartOptions.set({ + maintainAspectRatio: false, + aspectRatio: 0.6, + plugins: { + legend: { + labels: { + color: textColor, + }, + }, + }, + scales: { + x: { + ticks: { + color: textColorSecondary, + }, + grid: { + color: surfaceBorder, + drawBorder: false, + }, + }, + y: { + ticks: { + color: textColorSecondary, + }, + grid: { + color: surfaceBorder, + drawBorder: false, + }, + }, + }, + }); + this.changeDetector.detectChanges(); + } + onLoad(event: TableLazyLoadEvent) { this.lastLazyLoadEvent = event; @@ -176,5 +272,54 @@ export class UrlsComponent { menu.toggle(event); } + showHistory(history: UrlClickHistory, url: Url) { + this.history.set(null); + + const clicksPerDay: Record = {}; + + let currentDay = dayjs(url.createdAt) ?? dayjs().subtract(30, 'days'); + const today = dayjs().format(this.HISTORY_DATE_FORMAT); + + while (currentDay.format(this.HISTORY_DATE_FORMAT) !== today) { + clicksPerDay[currentDay.format(this.HISTORY_DATE_FORMAT)] = 0; + + currentDay = currentDay.add(1, 'days'); + } + + history.data.forEach(({ timestamp }) => { + const dayTimestamp = dayjs(timestamp).format( + this.HISTORY_DATE_FORMAT, + ); + + if (clicksPerDay[dayTimestamp]) { + clicksPerDay[dayTimestamp]++; + } else { + clicksPerDay[dayTimestamp] = 1; + } + }); + + if (Object.entries(clicksPerDay).length === 0) + return this.historyVisible.set(true); + + const labels = Object.keys(clicksPerDay); + const data = Object.values(clicksPerDay); + + const documentStyle = getComputedStyle(document.documentElement); + + this.history.set({ + labels, + datasets: [ + { + label: 'Clicks', + data, + borderColor: documentStyle.getPropertyValue('--p-red-500'), + hoverRadius: 12, + pointRadius: 8, + }, + ], + }); + this.historyVisible.set(true); + } + protected readonly DateUtils = DateUtils; } diff --git a/apps/ui/src/app/pastebin/pages/index/pastebin.component.ts b/apps/ui/src/app/pastebin/pages/index/pastebin.component.ts index 666f686..5c9fa48 100644 --- a/apps/ui/src/app/pastebin/pages/index/pastebin.component.ts +++ b/apps/ui/src/app/pastebin/pages/index/pastebin.component.ts @@ -23,14 +23,13 @@ import { DateUtils } from '../../../shared/utils/date'; import { QRCodeComponent } from 'angularx-qrcode'; import { PasswordModule } from 'primeng/password'; import { CheckboxModule } from 'primeng/checkbox'; -import { Button } from 'primeng/button'; import { TooltipModule } from 'primeng/tooltip'; import { DropdownModule } from 'primeng/dropdown'; import { InputTextModule } from 'primeng/inputtext'; import { HighlightLineNumbers } from 'ngx-highlightjs/line-numbers'; import { Highlight } from 'ngx-highlightjs'; import { TextareaModule } from 'primeng/textarea'; -import { NgIf, SlicePipe } from '@angular/common'; +import { SlicePipe } from '@angular/common'; import { SelectButtonModule } from 'primeng/selectbutton'; import { ButtonComponent } from '../../../shared/components/button/button.component'; import { TablerIconComponent } from 'angular-tabler-icons'; @@ -56,14 +55,12 @@ import { TablerIconComponent } from 'angular-tabler-icons'; ReactiveFormsModule, SelectButtonModule, FormsModule, - NgIf, TextareaModule, Highlight, HighlightLineNumbers, InputTextModule, DropdownModule, TooltipModule, - Button, CheckboxModule, PasswordModule, QRCodeComponent, diff --git a/apps/ui/src/app/pastebin/pages/paste/paste.component.ts b/apps/ui/src/app/pastebin/pages/paste/paste.component.ts index f488e8f..ce9e849 100644 --- a/apps/ui/src/app/pastebin/pages/paste/paste.component.ts +++ b/apps/ui/src/app/pastebin/pages/paste/paste.component.ts @@ -2,7 +2,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Paste, PastebinService } from '../../services/pastebin.service'; import { Highlight } from 'ngx-highlightjs'; -import { NgForOf, NgIf } from '@angular/common'; import { ButtonModule } from 'primeng/button'; import { ToastService } from '../../../shared/services/toast/toast.service'; import { ClipboardService } from 'ngx-clipboard'; @@ -24,10 +23,8 @@ import { ButtonComponent } from '../../../shared/components/button/button.compon imports: [ Highlight, HighlightLineNumbers, - NgIf, ButtonModule, SkeletonModule, - NgForOf, DialogModule, FormsModule, PasswordModule, diff --git a/apps/ui/src/app/shared/components/header/header.component.ts b/apps/ui/src/app/shared/components/header/header.component.ts index e844d42..e29f8e1 100644 --- a/apps/ui/src/app/shared/components/header/header.component.ts +++ b/apps/ui/src/app/shared/components/header/header.component.ts @@ -4,7 +4,7 @@ import { fadeInAnimation } from '../../animations/fadeIn'; import { fadeOutAnimation } from '../../animations/fadeOut'; import { SidebarModule } from 'primeng/sidebar'; import { FeaturesMenuComponent } from './components/features-menu.component'; -import { NgIf, NgOptimizedImage } from '@angular/common'; +import { NgOptimizedImage } from '@angular/common'; import { RouterLink } from '@angular/router'; import { TablerIconComponent } from 'angular-tabler-icons'; @@ -17,7 +17,6 @@ import { TablerIconComponent } from 'angular-tabler-icons'; imports: [ RouterLink, NgOptimizedImage, - NgIf, FeaturesMenuComponent, SidebarModule, TablerIconComponent, diff --git a/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts b/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts index 280679b..7364d8e 100644 --- a/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts +++ b/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts @@ -4,7 +4,7 @@ import { KeycloakService } from '../../../auth/services/keycloak.service'; import { User } from '../../../auth/interfaces/user'; import { OverlayPanelModule } from 'primeng/overlaypanel'; import { AvatarModule } from 'primeng/avatar'; -import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'; +import { NgClass, NgTemplateOutlet } from '@angular/common'; import { TablerIconComponent } from 'angular-tabler-icons'; type SidebarItem = { @@ -26,8 +26,6 @@ type SidebarItem = { OverlayPanelModule, NgClass, RouterLink, - NgFor, - NgIf, RouterOutlet, TablerIconComponent, ], diff --git a/apps/ui/src/app/url-shortener/pages/index/url-shortener-index.component.ts b/apps/ui/src/app/url-shortener/pages/index/url-shortener-index.component.ts index e290979..fcfceb8 100644 --- a/apps/ui/src/app/url-shortener/pages/index/url-shortener-index.component.ts +++ b/apps/ui/src/app/url-shortener/pages/index/url-shortener-index.component.ts @@ -13,13 +13,7 @@ import { format } from 'date-fns'; import { ToastService } from '../../../shared/services/toast/toast.service'; import { TooltipModule } from 'primeng/tooltip'; import { QRCodeComponent } from 'angularx-qrcode'; -import { NgIf, NgOptimizedImage, SlicePipe } from '@angular/common'; -import { - Button, - ButtonDirective, - ButtonIcon, - ButtonLabel, -} from 'primeng/button'; +import { NgOptimizedImage, SlicePipe } from '@angular/common'; import { InputTextModule } from 'primeng/inputtext'; import { TablerIconComponent } from 'angular-tabler-icons'; import { ButtonComponent } from '../../../shared/components/button/button.component'; @@ -32,16 +26,11 @@ import { ButtonComponent } from '../../../shared/components/button/button.compon imports: [ ReactiveFormsModule, InputTextModule, - Button, - NgIf, QRCodeComponent, TooltipModule, NgOptimizedImage, SlicePipe, - ButtonDirective, TablerIconComponent, - ButtonIcon, - ButtonLabel, ButtonComponent, ], providers: [UrlShortenerService], diff --git a/apps/ui/src/app/url-shortener/services/url-shortener.service.ts b/apps/ui/src/app/url-shortener/services/url-shortener.service.ts index 7b124f9..9822c60 100644 --- a/apps/ui/src/app/url-shortener/services/url-shortener.service.ts +++ b/apps/ui/src/app/url-shortener/services/url-shortener.service.ts @@ -24,6 +24,12 @@ export type PaginationParams = { pageSize?: string; }; +export type UrlClickHistoryPoint = { + timestamp: string; +}; + +export type UrlClickHistory = ApiResponse; + @Injectable() export class UrlShortenerService { private readonly BASE_PATH = '/url-shortener'; @@ -62,4 +68,10 @@ export class UrlShortenerService { }, }); } + + getHistory(id: number) { + return this.httpService.get( + this.BASE_PATH + '/' + id + '/history', + ); + } } diff --git a/apps/ui/src/environment/environment.dev.ts b/apps/ui/src/environment/environment.dev.ts new file mode 100644 index 0000000..5da97ba --- /dev/null +++ b/apps/ui/src/environment/environment.dev.ts @@ -0,0 +1,8 @@ +export const environment = { + production: false, + clientUrl: 'https://localhost', + apiUrl: 'https://api.localhost', + keycloakUrl: 'https://auth.localhost', + keycloakRealm: 'mally', + keycloadClientId: 'mally', +}; diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index a834de9..9eb6b25 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -40,6 +40,7 @@ import { IconDeviceFloppy, IconDotsVertical, IconExternalLink, + IconGraph, IconLayoutDashboard, IconLink, IconLoader2, @@ -134,6 +135,7 @@ bootstrapApplication(AppComponent, { IconCalendar, IconLogout2, IconUser, + IconGraph, }), ], }).catch((err) => console.error(err)); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 306118e..a278f2b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -57,6 +57,27 @@ services: - private command: start --hostname ${KEYCLOAK_URL} --import-realm + rabbitmq: + container_name: mally-rabbitmq + image: rabbitmq:4.0-management-alpine + environment: + RABBITMQ_DEFAULT_USER: ${RABBIT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBIT_PASSWORD} + + influxdb: + container_name: mally-influxdb + image: influxdb:2-alpine + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USER} + DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD} + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN} + DOCKER_INFLUXDB_INIT_ORG: ${INFLUX_ORG} + DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUX_BUCKET:-mally} + volumes: + - influxdb2-data:/var/lib/influxdb2 + - influxdb2-config:/etc/influxdb2 + api: container_name: mally-api build: @@ -202,6 +223,8 @@ volumes: postgres: grafana: letsencrypt: + influxdb2-data: + influxdb2-config: networks: public: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index beba0dd..877c730 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -51,6 +51,27 @@ services: - private command: start --hostname ${KEYCLOAK_URL} --import-realm + rabbitmq: + container_name: mally-rabbitmq + image: rabbitmq:4.0-management-alpine + environment: + RABBITMQ_DEFAULT_USER: ${RABBIT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBIT_PASSWORD} + + influxdb: + container_name: mally-influxdb + image: influxdb:2-alpine + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USER} + DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD} + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN} + DOCKER_INFLUXDB_INIT_ORG: ${INFLUX_ORG} + DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUX_BUCKET:-mally} + volumes: + - influxdb2-data:/var/lib/influxdb2 + - influxdb2-config:/etc/influxdb2 + api: image: ghcr.io/neumanf/mally-api restart: unless-stopped @@ -176,6 +197,8 @@ volumes: postgres: grafana: letsencrypt: + influxdb2-data: + influxdb2-config: networks: public: diff --git a/docker-compose.testing.yml b/docker-compose.test.yml similarity index 71% rename from docker-compose.testing.yml rename to docker-compose.test.yml index d8b475d..0d031b5 100644 --- a/docker-compose.testing.yml +++ b/docker-compose.test.yml @@ -59,11 +59,37 @@ services: - '9000:9000' command: start --hostname http://localhost:9090 --import-realm + rabbitmq: + container_name: mally-rabbitmq + image: rabbitmq:4.0-management-alpine + environment: + RABBITMQ_DEFAULT_USER: ${RABBIT_USER:-mally} + RABBITMQ_DEFAULT_PASS: ${RABBIT_PASSWORD:-mally} + ports: + - "5672:5672" + - "15672:15672" + + influxdb: + container_name: mally-influxdb + image: influxdb:2-alpine + ports: + - "8086:8086" + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USER:-mally} + DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD:-mally123} + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN:-mally123} + DOCKER_INFLUXDB_INIT_ORG: ${INFLUX_ORG:-mally} + DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUX_BUCKET:-mally} + volumes: + - influxdb2-data:/var/lib/influxdb2 + - influxdb2-config:/etc/influxdb2 + api: container_name: mally-api build: context: . - dockerfile: ./apps/api/testing.Dockerfile + dockerfile: ./apps/api/test.Dockerfile restart: unless-stopped networks: - mally-network @@ -77,6 +103,8 @@ services: volumes: postgres: + influxdb2-data: + influxdb2-config: networks: mally-network: diff --git a/docker-compose.yml b/docker-compose.yml index 4fc5d75..5e3944b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,8 +59,36 @@ services: - '9000:9000' command: start --hostname http://localhost:9090 --import-realm + rabbitmq: + container_name: mally-rabbitmq + image: rabbitmq:4.0-management-alpine + environment: + RABBITMQ_DEFAULT_USER: ${RABBIT_USER:-mally} + RABBITMQ_DEFAULT_PASS: ${RABBIT_PASSWORD:-mally} + ports: + - "5672:5672" + - "15672:15672" + + influxdb: + container_name: mally-influxdb + image: influxdb:2-alpine + ports: + - "8086:8086" + environment: + DOCKER_INFLUXDB_INIT_MODE: setup + DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUX_USER:-mally} + DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUX_PASSWORD:-mally123} + DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUX_TOKEN:-mally123} + DOCKER_INFLUXDB_INIT_ORG: ${INFLUX_ORG:-mally} + DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUX_BUCKET:-mally} + volumes: + - influxdb2-data:/var/lib/influxdb2 + - influxdb2-config:/etc/influxdb2 + volumes: postgres: + influxdb2-data: + influxdb2-config: networks: mally-network: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4f01016 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "mally", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "chart.js": "^4.4.7" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/chart.js": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz", + "integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b58bc39 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "chart.js": "^4.4.7" + } +}