diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a348e12..21545f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: - push: + pull_request: jobs: format: diff --git a/README.md b/README.md index 059fda5..85a4cbf 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,34 @@

- +

-

-Mally -

-

-A collection of open source web services -

+[Mally](https://mally.neumanf.com) is a web application that provides a collection of web services, including a URL shortener and a Pastebin-like service for sharing text and code snippets. It uses Angular for the frontend and Spring Boot for a modular monolith in the backend, along with Keycloak managing authentication, RabbitMQ handling message queuing, and PostgreSQL for data storage. It also has automated testing with Cypress and a fully configured CI/CD pipeline with GitHub Actions. -## Features +## Key Features -- URL Shortener and Pastebin services -- CI/CD -- Tests +- URL Shortener with QR Code for easy copy and share +- Pastebin supporting over 10 programming languages +- Dashboard with statistics about your usage +- Management page for viewing, creating, updating and deleting data +- Authentication -## Stack +## Folder structure -- **Frontend:** Angular and PrimeNG. -- **Backend:** Springboot, RabbitMQ, PostgreSQL and Redis. +```lua +mally +| +├── apps -- Main folder where the application code is located +| ├── ui -- Angular front-end application +| └── api -- Spring Boot back-end application +├── infra -- Infraesctructure-related Docker files and configuration +| ├── keycloak -- Keycloak themes and configuration +| ├── nginx -- Nginx configuration files and scripts +| └── postgres -- PostgreSQL scripts +└── scripts -- Development scripts +``` ## Architecture @@ -42,9 +49,9 @@ flowchart LR %% Flow UI -->|HTTP|BACKEND - RMQ <-->|AMQP|BACKEND subgraph BACKEND [API] + API <-->|AMQP|RMQ direction LR API --> POSTGRES API --> REDIS diff --git a/apps/api/src/main/java/com/mally/api/auth/AuthenticationManager.java b/apps/api/src/main/java/com/mally/api/auth/AuthenticationManager.java new file mode 100644 index 0000000..1308e88 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/auth/AuthenticationManager.java @@ -0,0 +1,21 @@ +package com.mally.api.auth; + +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +public class AuthenticationManager { + + public static boolean isAuthenticated() { + var user = SecurityContextHolder.getContext().getAuthentication(); + return user instanceof UserJwt; + } + + public static Optional getAuthenticatedUser() { + if (!isAuthenticated()) { + return Optional.empty(); + } + return Optional.of((UserJwt) SecurityContextHolder.getContext().getAuthentication()); + } + +} 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 e70cc7d..475d6ef 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,7 @@ package com.mally.api.auth; +import lombok.Getter; +import lombok.Setter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; @@ -7,6 +9,8 @@ import java.util.Collection; +@Getter +@Setter public class UserJwt extends JwtAuthenticationToken { private String firstName; @@ -17,19 +21,7 @@ public UserJwt(Jwt jwt, Collection authorities) { super(jwt, authorities); } - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; + public String getId() { + return getName(); } } diff --git a/apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java b/apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java index b5833c5..8ce50d8 100644 --- a/apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java +++ b/apps/api/src/main/java/com/mally/api/configuration/WebSecurityConfig.java @@ -36,7 +36,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource())) .exceptionHandling(customizer -> customizer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests((requests) -> requests - .requestMatchers("/url-shortener/**").permitAll() + .requestMatchers( "/url-shortener/shorten", "/url-shortener/redirect/**").permitAll() .requestMatchers("/pastebin/paste/**").permitAll() .requestMatchers("/health/**").permitAll() .requestMatchers("/auth/**").permitAll() 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 f7e57ef..ff47183 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 @@ -1,16 +1,21 @@ package com.mally.api.pastebin; +import com.mally.api.auth.AuthenticationManager; +import com.mally.api.auth.UserJwt; 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 jakarta.validation.Valid; import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Optional; @AllArgsConstructor @RestController @@ -19,46 +24,56 @@ public class PastebinController { private final PastebinService pastebinService; + @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 = pastebinService.findAll( + userId, + search, + PageRequest.of(pageNumber, pageSize, Sort.by(orderBy.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy)) + ); + + return ResponseEntity.ok(result); + } + @GetMapping("/paste/{slug}") public ResponseEntity findPaste(@PathVariable String slug) { - try { - Optional paste = pastebinService.findBySlug(slug); - return paste + return pastebinService.findBySlug(slug) .map(p -> ResponseEntity .ok() .body(ApiResponseDTO.success("Paste found successfully.", p))) .orElseGet(() -> ResponseEntity .badRequest() .body(ApiResponseDTO.error("Paste not found.", List.of()))); - } catch (Exception e) { - return ResponseEntity - .internalServerError() - .body( - ApiResponseDTO.error( - "An unexpected error occurred while finding the paste.", - List.of() - ) - ); - } } @PostMapping("/paste") public ResponseEntity paste(@Valid @RequestBody CreatePasteDTO dto) { - try { - Paste paste = pastebinService.create(dto); + var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null); + var paste = pastebinService.create(dto, userId); - return ResponseEntity + return ResponseEntity .ok() .body(ApiResponseDTO.success("Paste created successfully.", paste)); - } catch (Exception e) { - return ResponseEntity - .internalServerError() - .body( - ApiResponseDTO.error( - "An unexpected error occurred while creating the paste.", - List.of() - ) - ); - } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + pastebinService.delete(id); + + return ResponseEntity.ok(ApiResponseDTO.success("Paste deleted successfully.", null)); + } + + @DeleteMapping("bulk") + public ResponseEntity bulkDelete(@RequestParam List id) { + pastebinService.deleteMany(id); + + return ResponseEntity.ok(ApiResponseDTO.success("Pastes deleted successfully.", null)); } } diff --git a/apps/api/src/main/java/com/mally/api/pastebin/dtos/SearchPastesDTO.java b/apps/api/src/main/java/com/mally/api/pastebin/dtos/SearchPastesDTO.java new file mode 100644 index 0000000..1bf26f2 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/pastebin/dtos/SearchPastesDTO.java @@ -0,0 +1,29 @@ +package com.mally.api.pastebin.dtos; + +import com.mally.api.pastebin.entities.PasteSyntax; +import com.mally.api.shared.rest.dtos.PaginationDTO; +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SearchPastesDTO extends PaginationDTO { + @Nullable + String slug; + + @Nullable + String text; + + @Nullable + PasteSyntax syntax; + + @Nullable + Boolean encrypted; + + @Nullable + String createdAt; + + @Nullable + String expiresAt; +} diff --git a/apps/api/src/main/java/com/mally/api/pastebin/entities/Paste.java b/apps/api/src/main/java/com/mally/api/pastebin/entities/Paste.java index 4e7a515..82caead 100644 --- a/apps/api/src/main/java/com/mally/api/pastebin/entities/Paste.java +++ b/apps/api/src/main/java/com/mally/api/pastebin/entities/Paste.java @@ -1,5 +1,6 @@ package com.mally.api.pastebin.entities; +import jakarta.annotation.Nullable; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.*; @@ -38,6 +39,10 @@ public class Paste { @NotNull private boolean encrypted; + @Column() + @Nullable() + private String userId; + @Column(name = "created_at") @CreatedDate @NotNull diff --git a/apps/api/src/main/java/com/mally/api/pastebin/repositories/PastebinRepository.java b/apps/api/src/main/java/com/mally/api/pastebin/repositories/PastebinRepository.java index 5ece0e6..79c7cb2 100644 --- a/apps/api/src/main/java/com/mally/api/pastebin/repositories/PastebinRepository.java +++ b/apps/api/src/main/java/com/mally/api/pastebin/repositories/PastebinRepository.java @@ -2,6 +2,9 @@ import com.mally.api.pastebin.entities.Paste; import jakarta.transaction.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; @@ -12,11 +15,18 @@ import java.util.Optional; @Repository -public interface PastebinRepository extends CrudRepository { +public interface PastebinRepository extends CrudRepository, JpaSpecificationExecutor { + + Page findAll(Pageable pageable); + + Page findAllByUserId(String userId, Pageable pageable); + Optional findBySlug(String slug); @Transactional @Modifying @Query("DELETE FROM Paste p WHERE p.expiresAt < :now") void deleteExpiredPastes(@Param("now") ZonedDateTime now); + + Long countByUserId(String userId); } diff --git a/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java b/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java index d57ade1..eac3de0 100644 --- a/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java +++ b/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java @@ -1,13 +1,25 @@ package com.mally.api.pastebin.services; import com.mally.api.pastebin.dtos.CreatePasteDTO; +import com.mally.api.pastebin.dtos.SearchPastesDTO; import com.mally.api.pastebin.entities.Paste; import com.mally.api.pastebin.repositories.PastebinRepository; +import com.mally.api.shared.utils.PaginationUtils; import io.github.thibaultmeyer.cuid.CUID; +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; @AllArgsConstructor @@ -17,11 +29,13 @@ public class PastebinService { private final PastebinRepository pastebinRepository; + private final EntityManager entityManager; + public Optional findBySlug(String slug) { return pastebinRepository.findBySlug(slug); } - public Paste create(CreatePasteDTO dto) { + public Paste create(CreatePasteDTO dto, String userId) { final ZonedDateTime now = ZonedDateTime.now(); final CUID slug = CUID.randomCUID2(22); @@ -30,6 +44,7 @@ public Paste create(CreatePasteDTO dto) { .syntax(dto.getSyntax()) .slug(slug.toString()) .encrypted(dto.isEncrypted()) + .userId(userId) .createdAt(now) .expiresAt(now.plusDays(EXPIRES_IN_DAYS)) .build(); @@ -40,4 +55,26 @@ public Paste create(CreatePasteDTO dto) { public void deleteExpiredPastes() { pastebinRepository.deleteExpiredPastes(ZonedDateTime.now()); } + + public Long getStats(String userId) { + return pastebinRepository.countByUserId(userId); + } + + public Page findAll(String userId, String search, Pageable pageable) { + if (search != null && !search.isEmpty()) { + List searchFields = List.of("slug", "text", "syntax"); + + return PaginationUtils.paginateSearch(entityManager, Paste.class, searchFields, search, userId, pageable); + } + + return pastebinRepository.findAllByUserId(userId, pageable); + } + + public void delete(Long id) { + pastebinRepository.deleteById(id); + } + + public void deleteMany(List ids) { + pastebinRepository.deleteAllById(ids); + } } 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 new file mode 100644 index 0000000..533ddf2 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/rest/base/BaseController.java @@ -0,0 +1,24 @@ +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/PaginationDTO.java b/apps/api/src/main/java/com/mally/api/shared/rest/dtos/PaginationDTO.java new file mode 100644 index 0000000..4be6475 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/rest/dtos/PaginationDTO.java @@ -0,0 +1,15 @@ +package com.mally.api.shared.rest.dtos; + +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PaginationDTO { + @Nullable + Integer pageNumber = 1; + + @Nullable + Integer pageSize = 10; +} 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 new file mode 100644 index 0000000..b7d6255 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/shared/utils/PaginationUtils.java @@ -0,0 +1,109 @@ +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 java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class PaginationUtils { + + public static Page paginateSearch(final EntityManager entityManager, + final Class entityClass, + final List searchFields, + final String searchQuery, + final String userId, + final Pageable pageable) { + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(entityClass); + Root root = query.from(entityClass); + Sort.Order sort = pageable.getSort().toList().getFirst(); + + List predicates = new ArrayList<>(); + + searchFields.forEach(field -> + predicates.add(builder.like(builder.lower(root.get(field)), "%" + searchQuery.toLowerCase() + "%"))); + + query + .select(root) + .where( + builder.and( + builder.equal(root.get("userId"), userId), + builder.or(predicates.toArray(new Predicate[0])) + ) + ) + .orderBy(sort.getDirection().equals(Sort.Direction.ASC) + ? builder.asc(root.get(sort.getProperty())) + : builder.desc(root.get(sort.getProperty())) + ); + + List result = entityManager.createQuery(query) + .setFirstResult((int) pageable.getOffset()) + .setMaxResults(pageable.getPageSize()) + .getResultList(); + + long count = PaginationUtils.count(entityManager, builder, query, root); + + return new PageImpl<>(result, pageable, count); + } + + public static long count(final EntityManager entityManager, + final CriteriaBuilder cb, + final CriteriaQuery criteria, + final Root root) { + CriteriaQuery query = createCountQuery(cb, criteria, root, root.getModel().getJavaType()); + return entityManager.createQuery(query).getSingleResult(); + } + + private static CriteriaQuery createCountQuery(final CriteriaBuilder cb, + final CriteriaQuery criteria, + final Root root, + final Class entityClass) { + + final CriteriaQuery countQuery = cb.createQuery(Long.class); + final SqmCopyContext copyContext = SqmCopyContext.simpleContext(); + final Root countRoot = countQuery.from(entityClass); + copyContext.registerCopy(root, countRoot); + + doJoins(root.getJoins(), countRoot, copyContext); + doJoinsOnFetches(root.getFetches(), countRoot, copyContext); + + countQuery.select(cb.count(countRoot)); + countQuery.where(((SqmPredicate) criteria.getRestriction()).copy(copyContext)); + + countRoot.alias(root.getAlias()); + + return countQuery.distinct(criteria.isDistinct()); + } + + @SuppressWarnings("unchecked") + private static void doJoinsOnFetches(Set> joins, Root root, SqmCopyContext copyContext) { + doJoins((Set>) joins, root, copyContext); + } + + private static void doJoins(Set> joins, Root root, SqmCopyContext copyContext) { + for (Join join : joins) { + Join joined = root.join(join.getAttribute().getName(), join.getJoinType()); + joined.alias(join.getAlias()); + copyContext.registerCopy(join, joined); + doJoins(join.getJoins(), joined, copyContext); + } + } + + private static void doJoins(Set> joins, Join root, SqmCopyContext copyContext) { + for (Join join : joins) { + Join joined = root.join(join.getAttribute().getName(), join.getJoinType()); + joined.alias(join.getAlias()); + copyContext.registerCopy(join, joined); + doJoins(join.getJoins(), joined, copyContext); + } + } +} 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 new file mode 100644 index 0000000..7f3e60d --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/stats/StatsController.java @@ -0,0 +1,25 @@ +package com.mally.api.stats; + +import com.mally.api.auth.AuthenticationManager; +import com.mally.api.auth.UserJwt; +import com.mally.api.shared.rest.dtos.ApiResponseDTO; +import com.mally.api.stats.services.StatsService; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@AllArgsConstructor +@RestController +@RequestMapping("/stats") +public class StatsController { + + private final StatsService statsService; + + @GetMapping("/dashboard") + public ApiResponseDTO getDashboardStats() { + var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow(); + + return ApiResponseDTO.success(null, statsService.getDashboardStats(userId)); + } +} diff --git a/apps/api/src/main/java/com/mally/api/stats/dtos/DashboardStatsDTO.java b/apps/api/src/main/java/com/mally/api/stats/dtos/DashboardStatsDTO.java new file mode 100644 index 0000000..d950199 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/stats/dtos/DashboardStatsDTO.java @@ -0,0 +1,20 @@ +package com.mally.api.stats.dtos; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public final class DashboardStatsDTO { + private final UrlShortenerDashboardStats urlShortener; + private final PastebinDashboardStats pastebin; + + public DashboardStatsDTO(Long urlsCount, Long pastebinsCount) { + urlShortener = new UrlShortenerDashboardStats(urlsCount); + pastebin = new PastebinDashboardStats(pastebinsCount); + } +} + +record UrlShortenerDashboardStats(Long total) {} + +record PastebinDashboardStats(Long total) {} 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 new file mode 100644 index 0000000..a9c0329 --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/stats/services/StatsService.java @@ -0,0 +1,24 @@ +package com.mally.api.stats.services; + +import com.mally.api.pastebin.services.PastebinService; +import com.mally.api.stats.dtos.DashboardStatsDTO; +import com.mally.api.urlshortener.services.UrlShortenerService; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@AllArgsConstructor +@Service +public class StatsService { + + private final UrlShortenerService urlShortenerService; + + private final PastebinService pastebinService; + + public DashboardStatsDTO getDashboardStats(String userId) { + Long urlShortenerStats = urlShortenerService.getStats(userId); + Long pastebinStats = pastebinService.getStats(userId); + + return new DashboardStatsDTO(urlShortenerStats, pastebinStats); + } + +} 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 index 7efec8f..f598839 100644 --- a/apps/api/src/main/java/com/mally/api/urlshortener/UrlShortenerController.java +++ b/apps/api/src/main/java/com/mally/api/urlshortener/UrlShortenerController.java @@ -1,12 +1,22 @@ package com.mally.api.urlshortener; +import com.mally.api.auth.AuthenticationManager; +import com.mally.api.auth.UserJwt; +import com.mally.api.pastebin.dtos.SearchPastesDTO; +import com.mally.api.pastebin.entities.Paste; import com.mally.api.shared.rest.dtos.ApiResponseDTO; +import com.mally.api.urlshortener.dtos.SearchUrlsDTO; 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.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -20,36 +30,59 @@ public class UrlShortenerController { private final UrlShortenerService urlShortenerService; - @GetMapping("/redirect/{slug}") - public ResponseEntity redirect(@PathVariable String slug) { - try { - final Optional longUrl = urlShortenerService.findLongUrl(slug); + @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)) + ); - if (longUrl.isEmpty()) { - return ResponseEntity - .badRequest() - .body(ApiResponseDTO.error("URL not found or expired.", List.of())); - } + return ResponseEntity.ok(result); + } - Map data = Map.of("url", longUrl.get()); + @GetMapping("/redirect/{slug}") + public ResponseEntity redirect(@PathVariable String slug) { + final Optional longUrl = urlShortenerService.findLongUrl(slug); + if (longUrl.isEmpty()) { return ResponseEntity - .ok() - .body(ApiResponseDTO.success("Redirected successfully.", data)); - } catch (Exception e) { - return ResponseEntity - .internalServerError() - .body(ApiResponseDTO.error("An error occurred while redirecting.", List.of())); + .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) { - try { - final Url url = urlShortenerService.save(dto); - return ResponseEntity.ok().body(ApiResponseDTO.success("URL shortened successfully.", url)); - } catch (Exception e) { - return ResponseEntity.internalServerError().body(ApiResponseDTO.error("An unexpected error occurred while shortening the URL.", List.of())); - } + 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/dtos/SearchUrlsDTO.java b/apps/api/src/main/java/com/mally/api/urlshortener/dtos/SearchUrlsDTO.java new file mode 100644 index 0000000..322fefd --- /dev/null +++ b/apps/api/src/main/java/com/mally/api/urlshortener/dtos/SearchUrlsDTO.java @@ -0,0 +1,25 @@ +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/entities/Url.java b/apps/api/src/main/java/com/mally/api/urlshortener/entities/Url.java index 1560350..e2c5898 100644 --- a/apps/api/src/main/java/com/mally/api/urlshortener/entities/Url.java +++ b/apps/api/src/main/java/com/mally/api/urlshortener/entities/Url.java @@ -1,5 +1,6 @@ package com.mally.api.urlshortener.entities; +import jakarta.annotation.Nullable; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import lombok.*; @@ -30,6 +31,10 @@ public class Url { @NotNull private Boolean custom; + @Column() + @Nullable() + private String userId; + @Column(name = "created_at") @CreatedDate @NotNull 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/repositories/UrlShortenerRepository.java index 5d0267e..ea5bcdb 100644 --- a/apps/api/src/main/java/com/mally/api/urlshortener/repositories/UrlShortenerRepository.java +++ b/apps/api/src/main/java/com/mally/api/urlshortener/repositories/UrlShortenerRepository.java @@ -2,6 +2,8 @@ import com.mally.api.urlshortener.entities.Url; import jakarta.transaction.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; @@ -13,10 +15,15 @@ @Repository public interface UrlShortenerRepository extends CrudRepository { + + Page findAllByUserId(String userId, Pageable pageable); + Optional findBySlug(String slug); @Transactional @Modifying @Query("DELETE FROM 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/services/UrlShortenerService.java b/apps/api/src/main/java/com/mally/api/urlshortener/services/UrlShortenerService.java index 1b38a67..267b407 100644 --- 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 @@ -1,14 +1,20 @@ 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 @@ -17,6 +23,18 @@ 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); @@ -29,7 +47,7 @@ public Optional findLongUrl(String slug) { return url.map(Url::getUrl); } - public Url save(ShortenUrlDTO dto) { + public Url save(ShortenUrlDTO dto, String userId) { final CUID slug = CUID.randomCUID2(8); final ZonedDateTime createdAt = ZonedDateTime.now(); @@ -37,6 +55,7 @@ public Url save(ShortenUrlDTO dto) { .url(dto.getUrl()) .slug(slug.toString()) .custom(false) + .userId(userId) .createdAt(createdAt) .expiresAt(createdAt.plusDays(EXPIRES_IN_DAYS)) .build(); @@ -47,4 +66,16 @@ public Url save(ShortenUrlDTO dto) { 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/db/changelog/db.changelog-master.yaml b/apps/api/src/main/resources/db/changelog/db.changelog-master.yaml index c6d1228..b9d4720 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 @@ -1,7 +1,9 @@ databaseChangeLog: - include: - file: 'db/changelog/scripts/v1-add-url-table.yaml' + file: 'db/changelog/scripts/v1-add-url-table.yaml' - include: - file: 'db/changelog/scripts/v2-add-paste-table.yaml' + file: 'db/changelog/scripts/v2-add-paste-table.yaml' - include: - file: 'db/changelog/scripts/v3-add-encrypted-field-to-paste.yaml' + file: 'db/changelog/scripts/v3-add-encrypted-field-to-paste.yaml' + - include: + file: 'db/changelog/scripts/v4-add-user-id-to-url-and-paste.yaml' diff --git a/apps/api/src/main/resources/db/changelog/scripts/v4-add-user-id-to-url-and-paste.yaml b/apps/api/src/main/resources/db/changelog/scripts/v4-add-user-id-to-url-and-paste.yaml new file mode 100644 index 0000000..51fa058 --- /dev/null +++ b/apps/api/src/main/resources/db/changelog/scripts/v4-add-user-id-to-url-and-paste.yaml @@ -0,0 +1,25 @@ +databaseChangeLog: + - changeSet: + id: 4-1 + author: Neuman F. + changes: + - addColumn: + tableName: url + columns: + - column: + name: user_id + type: VARCHAR + constraints: + nullable: true + - changeSet: + id: 4-2 + author: Neuman F. + changes: + - addColumn: + tableName: paste + columns: + - column: + name: user_id + type: VARCHAR + constraints: + nullable: true diff --git a/apps/ui/angular.json b/apps/ui/angular.json index 72b986e..acb7611 100644 --- a/apps/ui/angular.json +++ b/apps/ui/angular.json @@ -50,8 +50,7 @@ "assets": ["src/favicon.ico", "src/assets"], "styles": [ "src/styles.scss", - "src/themes/lara-light-red/theme.css", - "node_modules/primeng/resources/primeng.min.css" + "src/themes/dark-theme.css" ], "scripts": [], "allowedCommonJsDependencies": [ diff --git a/apps/ui/package-lock.json b/apps/ui/package-lock.json index 849a522..d429aec 100644 --- a/apps/ui/package-lock.json +++ b/apps/ui/package-lock.json @@ -20,6 +20,7 @@ "crypto-js": "^4.2.0", "crypto.js": "^3.2.2", "date-fns": "^3.6.0", + "dayjs": "^1.11.13", "highlight.js": "^11.10.0", "keycloak-js": "^25.0.4", "ngx-clipboard": "^16.0.0", @@ -7931,11 +7932,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", - "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==", - "dev": true, - "license": "MIT" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/debug": { "version": "4.3.5", diff --git a/apps/ui/package.json b/apps/ui/package.json index 9dce17e..0f49036 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -3,10 +3,9 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "NG_BUILD_PARALLEL_TS=0 ng serve", + "start": "NG_BUILD_PARALLEL_TS=0 ng serve --host 0.0.0.0 --open", "build": "ng build", "watch": "ng build --watch --configuration development", - "dev": "ng serve --host 0.0.0.0", "format": "prettier --write --cache .", "lint": "eslint .", "cy:open": "cypress open", @@ -26,6 +25,7 @@ "crypto-js": "^4.2.0", "crypto.js": "^3.2.2", "date-fns": "^3.6.0", + "dayjs": "^1.11.13", "highlight.js": "^11.10.0", "keycloak-js": "^25.0.4", "ngx-clipboard": "^16.0.0", diff --git a/apps/ui/src/app/app.module.ts b/apps/ui/src/app/app.module.ts index d170a27..453eb54 100644 --- a/apps/ui/src/app/app.module.ts +++ b/apps/ui/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AppComponent } from './app.component'; import { RouterModule } from '@angular/router'; @@ -14,6 +14,7 @@ import { keycloakFactory, KeycloakService, } from './auth/services/keycloak.service'; +import { PrimeNGConfig } from 'primeng/api'; @NgModule({ bootstrap: [AppComponent], @@ -58,4 +59,10 @@ import { }, ], }) -export class AppModule {} +export class AppModule implements OnInit { + constructor(private primengConfig: PrimeNGConfig) {} + + ngOnInit() { + this.primengConfig.ripple = true; + } +} diff --git a/apps/ui/src/app/auth/interfaces/user.ts b/apps/ui/src/app/auth/interfaces/user.ts index 8817ada..59b4b1b 100644 --- a/apps/ui/src/app/auth/interfaces/user.ts +++ b/apps/ui/src/app/auth/interfaces/user.ts @@ -1,6 +1,58 @@ -export interface User { +import { KeycloakProfile, KeycloakTokenParsed } from 'keycloak-js'; + +export class User { + id?: string; username?: string; email?: string; firstName?: string; lastName?: string; + emailVerified?: boolean; + roles?: string[]; + + isAdmin() { + return !!this.roles?.includes(UserRole.ADMIN); + } + + isFreeUser() { + return !!this.roles?.includes(UserRole.FREE_USER); + } + + get role() { + if (this.isAdmin()) return UserRole.ADMIN; + return UserRole.FREE_USER; + } + + get formattedRole() { + switch (this.role) { + case 'ADMIN': + return 'Admin'; + case 'FREE_USER': + return 'Free plan'; + default: + return ''; + } + } + + get fullName() { + return `${this.firstName?.split(' ').at(0)} ${this.lastName?.split(' ').at(0)}`; + } + + static fromKeycloak(profile: KeycloakProfile, jwt?: KeycloakTokenParsed) { + const user = new User(); + user.id = profile?.id; + user.username = profile?.username; + user.email = profile?.email; + user.firstName = profile?.firstName; + user.lastName = profile?.lastName; + user.emailVerified = profile?.emailVerified; + user.roles = jwt?.realm_access?.roles?.filter( + (role) => role === role.toUpperCase(), + ); + return user; + } +} + +export enum UserRole { + ADMIN = 'ADMIN', + FREE_USER = 'FREE_USER', } diff --git a/apps/ui/src/app/auth/services/keycloak.service.ts b/apps/ui/src/app/auth/services/keycloak.service.ts index 336a347..6995724 100644 --- a/apps/ui/src/app/auth/services/keycloak.service.ts +++ b/apps/ui/src/app/auth/services/keycloak.service.ts @@ -31,7 +31,10 @@ export class KeycloakService { }); if (authenticated) { - this._user = await this.keycloak?.loadUserProfile(); + const userProfile = await this.keycloak.loadUserProfile(); + const jwt = this.keycloak.tokenParsed; + + this._user = User.fromKeycloak(userProfile, jwt); } } diff --git a/apps/ui/src/app/dashboard/dashboard-routing.module.ts b/apps/ui/src/app/dashboard/dashboard-routing.module.ts index 7a7e32c..3d55d8c 100644 --- a/apps/ui/src/app/dashboard/dashboard-routing.module.ts +++ b/apps/ui/src/app/dashboard/dashboard-routing.module.ts @@ -1,12 +1,22 @@ import { NgModule } from '@angular/core'; import { DashboardIndexComponent } from './pages/index/dashboard-index.component'; import { RouterModule, Routes } from '@angular/router'; +import { UrlsComponent } from './pages/urls/urls.component'; +import { PastesComponent } from './pages/pastes/pastes.component'; const routes: Routes = [ { path: '', component: DashboardIndexComponent, }, + { + path: 'short-urls', + component: UrlsComponent, + }, + { + path: 'pastes', + component: PastesComponent, + }, ]; @NgModule({ diff --git a/apps/ui/src/app/dashboard/dashboard.module.ts b/apps/ui/src/app/dashboard/dashboard.module.ts index b63d76d..f9b237b 100644 --- a/apps/ui/src/app/dashboard/dashboard.module.ts +++ b/apps/ui/src/app/dashboard/dashboard.module.ts @@ -2,10 +2,42 @@ import { NgModule } from '@angular/core'; import { CommonModule, NgOptimizedImage } from '@angular/common'; import { DashboardIndexComponent } from './pages/index/dashboard-index.component'; import { DashboardRoutingModule } from './dashboard-routing.module'; +import { UrlsComponent } from './pages/urls/urls.component'; +import { DropdownModule } from 'primeng/dropdown'; +import { InputTextModule } from 'primeng/inputtext'; +import { TableModule } from 'primeng/table'; +import { ToastModule } from 'primeng/toast'; +import { Button } from 'primeng/button'; +import { ToolbarModule } from 'primeng/toolbar'; +import { MenuModule } from 'primeng/menu'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { ConfirmationService } from 'primeng/api'; +import { StatsService } from '../stats/services/stats.service'; +import { SkeletonModule } from 'primeng/skeleton'; +import { StatsCardComponent } from './pages/index/stats-card/stats-card.component'; +import { PastesComponent } from './pages/pastes/pastes.component'; @NgModule({ - declarations: [DashboardIndexComponent], - imports: [CommonModule, DashboardRoutingModule, NgOptimizedImage], - providers: [], + declarations: [ + DashboardIndexComponent, + UrlsComponent, + StatsCardComponent, + PastesComponent, + ], + imports: [ + CommonModule, + DashboardRoutingModule, + NgOptimizedImage, + DropdownModule, + InputTextModule, + TableModule, + ToastModule, + Button, + ToolbarModule, + MenuModule, + ConfirmDialogModule, + SkeletonModule, + ], + providers: [ConfirmationService, StatsService], }) export class DashboardModule {} diff --git a/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html b/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html index 9dfe6ef..0def531 100644 --- a/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html +++ b/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html @@ -1,3 +1,19 @@

Dashboard

-Nothing here yet. Coming soon... \ No newline at end of file +
+ + + + +
+ + +
+ + +
+
+ + +
+
\ No newline at end of file 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 d5aacae..bcf8466 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 @@ -1,8 +1,39 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { + DashboardStats, + StatsService, +} from '../../../stats/services/stats.service'; +import { ToastService } from '../../../shared/services/toast/toast.service'; @Component({ selector: 'app-dashboard-index', templateUrl: './dashboard-index.component.html', styleUrl: './dashboard-index.component.scss', }) -export class DashboardIndexComponent {} +export class DashboardIndexComponent implements OnInit { + stats?: DashboardStats; + loading = false; + + constructor( + private readonly statsService: StatsService, + private readonly toastService: ToastService, + ) {} + + ngOnInit() { + this.getDashboardStats(); + } + + getDashboardStats() { + this.loading = true; + this.statsService.getDashboardStats().subscribe({ + next: (data) => { + this.stats = data.data; + this.loading = false; + }, + error: (err) => { + this.toastService.error(err.error.message); + this.loading = false; + }, + }); + } +} diff --git a/apps/ui/src/app/dashboard/pages/index/stats-card/stats-card.component.ts b/apps/ui/src/app/dashboard/pages/index/stats-card/stats-card.component.ts new file mode 100644 index 0000000..3792803 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/index/stats-card/stats-card.component.ts @@ -0,0 +1,26 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-stats-card', + template: ` +
+
+ {{ title }} + {{ data }} +
+
+ +
+
+ `, + styles: ``, +}) +export class StatsCardComponent { + @Input() title!: string; + @Input() data?: number; + @Input() link!: string; +} diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html new file mode 100644 index 0000000..b42b6e4 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html @@ -0,0 +1,94 @@ + + + +

Pastes

+ + + +
+ + + + +
+ +
+
+
+ + + + + + + URL + + + Syntax + + + Text + + + Created at + + + Expires in + + + + + + + + + + + {{ window.location.hostname + '/p/' + paste.slug }} + + + + {{ paste.syntax }} + + + + {{ paste.text }} + + + {{ paste.createdAt | date: 'short' }} + + + {{ pasteExpiresIn(paste.expiresAt, 'humanized') }} + + + + + + + +
diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.scss b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.ts b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.ts new file mode 100644 index 0000000..84bfd65 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.ts @@ -0,0 +1,166 @@ +import { Component } from '@angular/core'; +import { PaginationParams } from '../../../url-shortener/services/url-shortener.service'; +import { Url } from '../../../shared/interfaces/url'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + ConfirmationService, + MenuItem, + MenuItemCommandEvent, +} from 'primeng/api'; +import dayjs from 'dayjs'; +import { Menu } from 'primeng/menu'; +import { ToastService } from '../../../shared/services/toast/toast.service'; +import { Page } from '../../../shared/interfaces/http'; +import { TableLazyLoadEvent } from 'primeng/table'; +import { + Paste, + PastebinService, +} from '../../../pastebin/services/pastebin.service'; + +@Component({ + selector: 'app-pastes', + templateUrl: 'pastes.component.html', + styleUrl: 'pastes.component.scss', +}) +export class PastesComponent { + data?: Page; + selectedUrls: Url[] = []; + optionsItems: MenuItem[] = [ + { + label: 'Options', + items: [ + { + label: 'Delete', + icon: 'ti ti-trash', + command: (event: MenuItemCommandEvent) => { + const id = event.item?.['data']['id']; + + if (!id) return; + + this.confirmationService.confirm({ + target: event.originalEvent?.target as EventTarget, + message: + 'Are you sure you want to delete this Paste?', + acceptIcon: 'none', + rejectIcon: 'none', + rejectButtonStyleClass: 'p-button-text', + header: 'Confirmation', + icon: 'ti ti-alert-triangle', + accept: () => this.delete(id), + }); + }, + }, + ], + }, + ]; + + protected readonly window = window; + private lastLazyLoadEvent!: TableLazyLoadEvent; + private searchQuery?: string; + protected loadingData: boolean = false; + + constructor( + private readonly pastebinService: PastebinService, + private readonly confirmationService: ConfirmationService, + private readonly toastService: ToastService, + ) {} + + onLoad(event: TableLazyLoadEvent) { + this.lastLazyLoadEvent = event; + + const sortBy = event.sortField as string | undefined; + const orderBy = event.sortOrder === 1 ? 'ASC' : 'DESC'; + const search = this.searchQuery; + const pageNumber = + event.first === 0 || event.first == undefined + ? 0 + : event.first / (event.rows == undefined ? 1 : event.rows); + + this.fetchUrls({ + sortBy, + orderBy, + search, + pageNumber: pageNumber.toString(), + }); + } + + fetchUrls(params?: PaginationParams) { + this.loadingData = true; + this.pastebinService.findAll(params).subscribe({ + next: (data) => { + this.data = data; + this.loadingData = false; + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + this.loadingData = false; + }, + }); + } + + pasteExpiresIn(expiresAt: string, format: 'humanized' | 'long') { + if (format === 'humanized') { + const expiration = dayjs(expiresAt); + if (expiration < dayjs()) { + return 'Expired'; + } else { + return expiration.fromNow(); + } + } else { + return dayjs(expiresAt).format('MMMM D, YYYY h:mm A'); + } + } + + delete(id: number) { + this.pastebinService.delete(id).subscribe({ + next: () => { + this.toastService.success('Paste deleted successfully'); + this.fetchUrls(); + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + }, + }); + } + + deleteMany() { + const ids = this.selectedUrls.map((url) => url.id); + + this.confirmationService.confirm({ + message: 'Are you sure you want to delete these Pastes?', + acceptIcon: 'none', + rejectIcon: 'none', + rejectButtonStyleClass: 'p-button-text', + header: 'Confirmation', + icon: 'ti ti-alert-triangle', + accept: () => { + this.pastebinService.deleteMany(ids).subscribe({ + next: () => { + this.toastService.success( + 'Pastes deleted successfully', + ); + this.fetchUrls(); + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + }, + }); + }, + }); + } + + search(event: Event) { + this.searchQuery = (event.target as HTMLInputElement).value; + + this.onLoad(this.lastLazyLoadEvent); + } + + openOptionsMenu(menu: Menu, event: MouseEvent, url: Url) { + const options = this.optionsItems.at(0); + + options?.items?.forEach((item) => { + item['data'] = url; + }); + menu.toggle(event); + } +} diff --git a/apps/ui/src/app/dashboard/pages/urls/urls.component.html b/apps/ui/src/app/dashboard/pages/urls/urls.component.html new file mode 100644 index 0000000..93c67f4 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/urls/urls.component.html @@ -0,0 +1,99 @@ + + + +

URLs

+ + + +
+ + + + +
+ +
+
+
+ + + + + + + Short URL + + + Original URL + + + Custom + + + Created at + + + Expires in + + + + + + + + + + + {{ window.location.hostname + '/s/' + url.slug }} + + + {{ url.url }} + + + + + + + {{ url.createdAt | date: 'short' }} + + + {{ urlExpiresIn(url.expiresAt, 'humanized') }} + + + + + + + +
diff --git a/apps/ui/src/app/dashboard/pages/urls/urls.component.scss b/apps/ui/src/app/dashboard/pages/urls/urls.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/ui/src/app/dashboard/pages/urls/urls.component.ts b/apps/ui/src/app/dashboard/pages/urls/urls.component.ts new file mode 100644 index 0000000..85553c9 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/urls/urls.component.ts @@ -0,0 +1,163 @@ +import { Component, OnInit } from '@angular/core'; +import { + PaginationParams, + UrlShortenerService, +} from '../../../url-shortener/services/url-shortener.service'; +import { Url } from '../../../shared/interfaces/url'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + ConfirmationService, + MenuItem, + MenuItemCommandEvent, +} from 'primeng/api'; +import dayjs from 'dayjs'; +import { Menu } from 'primeng/menu'; +import { ToastService } from '../../../shared/services/toast/toast.service'; +import { Page } from '../../../shared/interfaces/http'; +import { TableLazyLoadEvent } from 'primeng/table'; + +@Component({ + selector: 'app-urls', + templateUrl: 'urls.component.html', + styleUrl: 'urls.component.scss', +}) +export class UrlsComponent { + data?: Page; + selectedUrls: Url[] = []; + optionsItems: MenuItem[] = [ + { + label: 'Options', + items: [ + { + label: 'Delete', + icon: 'ti ti-trash', + command: (event: MenuItemCommandEvent) => { + const id = event.item?.['data']['id']; + + if (!id) return; + + this.confirmationService.confirm({ + target: event.originalEvent?.target as EventTarget, + message: + 'Are you sure you want to delete this URL?', + acceptIcon: 'none', + rejectIcon: 'none', + rejectButtonStyleClass: 'p-button-text', + header: 'Confirmation', + icon: 'ti ti-alert-triangle', + accept: () => this.delete(id), + }); + }, + }, + ], + }, + ]; + + protected readonly window = window; + private lastLazyLoadEvent!: TableLazyLoadEvent; + private searchQuery?: string; + protected loadingData: boolean = false; + + constructor( + private readonly urlShortenerService: UrlShortenerService, + private readonly confirmationService: ConfirmationService, + private readonly toastService: ToastService, + ) {} + + onLoad(event: TableLazyLoadEvent) { + this.lastLazyLoadEvent = event; + + const sortBy = event.sortField as string | undefined; + const orderBy = event.sortOrder === 1 ? 'ASC' : 'DESC'; + const search = this.searchQuery; + const pageNumber = + event.first === 0 || event.first == undefined + ? 0 + : event.first / (event.rows == undefined ? 1 : event.rows); + + this.fetchUrls({ + sortBy, + orderBy, + search, + pageNumber: pageNumber.toString(), + }); + } + + fetchUrls(params?: PaginationParams) { + this.loadingData = true; + this.urlShortenerService.findAll(params).subscribe({ + next: (data) => { + this.data = data; + this.loadingData = false; + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + this.loadingData = false; + }, + }); + } + + urlExpiresIn(expiresAt: string, format: 'humanized' | 'long') { + if (format === 'humanized') { + const expiration = dayjs(expiresAt); + if (expiration < dayjs()) { + return 'Expired'; + } else { + return expiration.fromNow(); + } + } else { + return dayjs(expiresAt).format('MMMM D, YYYY h:mm A'); + } + } + + delete(id: number) { + this.urlShortenerService.delete(id).subscribe({ + next: () => { + this.toastService.success('URL deleted successfully'); + this.fetchUrls(); + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + }, + }); + } + + deleteMany() { + const ids = this.selectedUrls.map((url) => url.id); + + this.confirmationService.confirm({ + message: 'Are you sure you want to delete these URLs?', + acceptIcon: 'none', + rejectIcon: 'none', + rejectButtonStyleClass: 'p-button-text', + header: 'Confirmation', + icon: 'ti ti-alert-triangle', + accept: () => { + this.urlShortenerService.deleteMany(ids).subscribe({ + next: () => { + this.toastService.success('URLs deleted successfully'); + this.fetchUrls(); + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + }, + }); + }, + }); + } + + search(event: Event) { + this.searchQuery = (event.target as HTMLInputElement).value; + + this.onLoad(this.lastLazyLoadEvent); + } + + openOptionsMenu(menu: Menu, event: MouseEvent, url: Url) { + const options = this.optionsItems.at(0); + + options?.items?.forEach((item) => { + item['data'] = url; + }); + menu.toggle(event); + } +} diff --git a/apps/ui/src/app/landing/pages/index/landing-index.component.html b/apps/ui/src/app/landing/pages/index/landing-index.component.html index 219a484..08ca6cf 100644 --- a/apps/ui/src/app/landing/pages/index/landing-index.component.html +++ b/apps/ui/src/app/landing/pages/index/landing-index.component.html @@ -77,7 +77,7 @@

ones, while being able to track its stats.

Try it now @@ -99,7 +99,7 @@

Share any text or piece of code over the web easily.

Try it now diff --git a/apps/ui/src/app/pastebin/services/pastebin.service.ts b/apps/ui/src/app/pastebin/services/pastebin.service.ts index db83cb1..c7973d4 100644 --- a/apps/ui/src/app/pastebin/services/pastebin.service.ts +++ b/apps/ui/src/app/pastebin/services/pastebin.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core'; -import { environment } from '../../../environment/environment'; -import { HttpClient } from '@angular/common/http'; -import { ApiResponse } from '../../shared/interfaces/http'; +import { ApiResponse, Page } from '../../shared/interfaces/http'; +import { HttpService } from '../../shared/services/http/http.service'; +import { ObjectUtils } from '../../shared/utils/object'; +import { PaginationParams } from '../../url-shortener/services/url-shortener.service'; export type Paste = { id: number; @@ -19,25 +20,46 @@ export type PasteRequest = { }; export type PasteResponse = ApiResponse; +export type PastesReponse = Page; @Injectable({ providedIn: 'root', }) export class PastebinService { - static readonly API_URL = environment.apiUrl + '/pastebin'; + private readonly BASE_PATH = '/pastebin'; - constructor(private readonly httpClient: HttpClient) {} + constructor(private readonly httpService: HttpService) {} + + findAll(options: PaginationParams = {}) { + const params = ObjectUtils.filterDefinedValues(options); + + return this.httpService.get(this.BASE_PATH + '/', { + params, + }); + } get(slug: string) { - return this.httpClient.get( - PastebinService.API_URL + '/paste/' + slug, + return this.httpService.get( + this.BASE_PATH + '/paste/' + slug, ); } save(data: PasteRequest) { - return this.httpClient.post( - PastebinService.API_URL + '/paste', + return this.httpService.post( + this.BASE_PATH + '/paste', data, ); } + + delete(id: number) { + return this.httpService.delete(this.BASE_PATH + '/' + id); + } + + deleteMany(ids: number[]) { + return this.httpService.delete(this.BASE_PATH + '/bulk', { + params: { + id: ids.map((id) => id.toString()), + }, + }); + } } diff --git a/apps/ui/src/app/shared/components/footer/footer.component.html b/apps/ui/src/app/shared/components/footer/footer.component.html index f1772d6..beeb5ad 100644 --- a/apps/ui/src/app/shared/components/footer/footer.component.html +++ b/apps/ui/src/app/shared/components/footer/footer.component.html @@ -46,7 +46,7 @@