diff --git a/src/main/java/org/blueline/api/config/ActivityInterceptor.java b/src/main/java/org/blueline/api/config/ActivityInterceptor.java new file mode 100644 index 0000000..c037a6b --- /dev/null +++ b/src/main/java/org/blueline/api/config/ActivityInterceptor.java @@ -0,0 +1,34 @@ +package org.blueline.api.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.blueline.api.service.ActivityLogService; +import org.blueline.api.service.AuthService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class ActivityInterceptor implements HandlerInterceptor { + + private final ActivityLogService activityLogService; + private final AuthService authService; + + public ActivityInterceptor(ActivityLogService activityLogService, AuthService authService) { + this.activityLogService = activityLogService; + this.authService = authService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + String action = request.getMethod(); + String endpoint = request.getRequestURI(); + + activityLogService.saveActivity(authentication, action, endpoint); + return true; + } +} + diff --git a/src/main/java/org/blueline/api/config/WebConfig.java b/src/main/java/org/blueline/api/config/WebConfig.java index b3bad9c..d1f7792 100644 --- a/src/main/java/org/blueline/api/config/WebConfig.java +++ b/src/main/java/org/blueline/api/config/WebConfig.java @@ -5,12 +5,26 @@ import org.springframework.lang.NonNull; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableWebMvc public class WebConfig implements WebMvcConfigurer { + private final ActivityInterceptor activityInterceptor; + + public WebConfig(ActivityInterceptor activityInterceptor) { + this.activityInterceptor = activityInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(activityInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/users/login", "/api/users/register"); + } + @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { diff --git a/src/main/java/org/blueline/api/controller/ActivityLogController.java b/src/main/java/org/blueline/api/controller/ActivityLogController.java new file mode 100644 index 0000000..4d1b25a --- /dev/null +++ b/src/main/java/org/blueline/api/controller/ActivityLogController.java @@ -0,0 +1,64 @@ +package org.blueline.api.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.blueline.api.model.dto.ActiveUsersDto; +import org.blueline.api.model.dto.ExceptionDto; +import org.blueline.api.model.enums.Gender; +import org.blueline.api.model.enums.Period; +import org.blueline.api.model.enums.Status; +import org.blueline.api.model.enums.UserAction; +import org.blueline.api.service.ActivityLogService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/stats") +@RequiredArgsConstructor +@Validated +@Tag(name = "Activity", description = "Manage activity statistics of users") +public class ActivityLogController { + + private final ActivityLogService activityLogService; + + @GetMapping("/activeUsers") + @Operation( + summary = "Get number of active users by period", + description = "Get number of active users by period", + responses = { + @ApiResponse(responseCode = "200", description = "Actives users found"), + @ApiResponse(responseCode = "400", description = "Bad request", content = @Content(schema = @Schema(implementation = ExceptionDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ExceptionDto.class))), + @ApiResponse(responseCode = "403", description = "Forbidden", content = @Content(schema = @Schema(implementation = ExceptionDto.class))), + @ApiResponse(responseCode = "404", description = "Actives users not found", content = @Content(schema = @Schema(implementation = ExceptionDto.class))) + }, + security = @SecurityRequirement(name = "bearerAuth") + ) + public ResponseEntity getActiveUsers(Authentication authentication, @RequestParam String startDate, + @RequestParam String endDate, + @RequestParam Period period, + @RequestParam(required = false) Status userStatus, + @RequestParam(required = false) Gender gender, + @RequestParam(required = false) UserAction userAction) { + return activityLogService.getActiveUsers( + authentication, + startDate, + endDate, + period, + userStatus, + gender, + userAction + ); + } + +} diff --git a/src/main/java/org/blueline/api/controller/UserController.java b/src/main/java/org/blueline/api/controller/UserController.java index 4647b01..f05a1b7 100644 --- a/src/main/java/org/blueline/api/controller/UserController.java +++ b/src/main/java/org/blueline/api/controller/UserController.java @@ -64,6 +64,21 @@ public ResponseEntity login( return new ResponseEntity<>(authService.login(loginDto), HttpStatus.OK); } + @PostMapping("/login/admin") + @Operation( + summary = "Login an admin user", + description = "There is no need to be authenticated to login a user. The token returned is used to authenticate the user in the future.", + responses = { + @ApiResponse(responseCode = "200", description = "User logged in"), + @ApiResponse(responseCode = "400", description = "Bad request", content = @Content(schema = @Schema(implementation = ExceptionDto.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ExceptionDto.class))) + } + ) + public ResponseEntity loginAdmin( + @Valid @RequestBody LoginDto loginDto) { + return new ResponseEntity<>(authService.loginAdmin(loginDto), HttpStatus.OK); + } + @GetMapping("/me") @Operation( summary = "Get the authenticated user", diff --git a/src/main/java/org/blueline/api/model/ActivityLog.java b/src/main/java/org/blueline/api/model/ActivityLog.java new file mode 100644 index 0000000..a5e871a --- /dev/null +++ b/src/main/java/org/blueline/api/model/ActivityLog.java @@ -0,0 +1,29 @@ +package org.blueline.api.model; +import jakarta.persistence.*; +import lombok.Data; +import org.blueline.api.model.enums.UserAction; + +import java.time.LocalDateTime; + +@Entity +@Data +@Table(name = "activity_logs") +public class ActivityLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserAction action; + + @Column(nullable = false) + private LocalDateTime timestamp; + +} + diff --git a/src/main/java/org/blueline/api/model/User.java b/src/main/java/org/blueline/api/model/User.java index 80b78ad..020e53e 100644 --- a/src/main/java/org/blueline/api/model/User.java +++ b/src/main/java/org/blueline/api/model/User.java @@ -40,7 +40,7 @@ public class User { @Column(name = "gender") private Gender gender; - @Column(name = "avatar") + @Column(name = "avatar", length = 750) private String avatar; @Enumerated(EnumType.STRING) diff --git a/src/main/java/org/blueline/api/model/dto/ActiveUsersDto.java b/src/main/java/org/blueline/api/model/dto/ActiveUsersDto.java new file mode 100644 index 0000000..ef27e14 --- /dev/null +++ b/src/main/java/org/blueline/api/model/dto/ActiveUsersDto.java @@ -0,0 +1,18 @@ +package org.blueline.api.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.sql.Timestamp; +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ActiveUsersDto { + + private int totalActiveUsers; + + private Map activeUsersPerPeriod; +} diff --git a/src/main/java/org/blueline/api/model/enums/Period.java b/src/main/java/org/blueline/api/model/enums/Period.java new file mode 100644 index 0000000..f9f289d --- /dev/null +++ b/src/main/java/org/blueline/api/model/enums/Period.java @@ -0,0 +1,8 @@ +package org.blueline.api.model.enums; + +public enum Period { + DAY, + WEEK, + MONTH, + YEAR +} diff --git a/src/main/java/org/blueline/api/model/enums/UserAction.java b/src/main/java/org/blueline/api/model/enums/UserAction.java new file mode 100644 index 0000000..6d52148 --- /dev/null +++ b/src/main/java/org/blueline/api/model/enums/UserAction.java @@ -0,0 +1,9 @@ +package org.blueline.api.model.enums; + +public enum UserAction { + CREATE_EVENT, + CREATE_CHALLENGE, + JOIN_EVENT, + JOIN_CHALLENGE, + OTHER +} diff --git a/src/main/java/org/blueline/api/repository/ActivityLogRepository.java b/src/main/java/org/blueline/api/repository/ActivityLogRepository.java new file mode 100644 index 0000000..8271221 --- /dev/null +++ b/src/main/java/org/blueline/api/repository/ActivityLogRepository.java @@ -0,0 +1,28 @@ +package org.blueline.api.repository; + +import org.blueline.api.model.ActivityLog; +import org.blueline.api.model.enums.Gender; +import org.blueline.api.model.enums.Status; +import org.blueline.api.model.enums.UserAction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ActivityLogRepository extends JpaRepository { + + @Query("SELECT a FROM ActivityLog a " + + "WHERE a.timestamp BETWEEN :start AND :end " + + "AND (:userStatus IS NULL OR a.user.status = :userStatus) " + + "AND (:gender IS NULL OR a.user.gender = :gender)" + + "AND (:userAction IS NULL OR a.action = :userAction)") + List findActiveUsers( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + @Param("userStatus") Status userStatus, + @Param("gender") Gender gender, + @Param("userAction") UserAction userAction + ); +} diff --git a/src/main/java/org/blueline/api/service/ActivityLogService.java b/src/main/java/org/blueline/api/service/ActivityLogService.java new file mode 100644 index 0000000..ad07f25 --- /dev/null +++ b/src/main/java/org/blueline/api/service/ActivityLogService.java @@ -0,0 +1,129 @@ +package org.blueline.api.service; + +import lombok.RequiredArgsConstructor; +import org.blueline.api.exception.UnauthorizedException; +import org.blueline.api.model.ActivityLog; +import org.blueline.api.model.User; +import org.blueline.api.model.dto.ActiveUsersDto; +import org.blueline.api.model.enums.Gender; +import org.blueline.api.model.enums.Period; +import org.blueline.api.model.enums.Status; +import org.blueline.api.model.enums.UserAction; +import org.blueline.api.repository.ActivityLogRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.security.core.Authentication; + + +import java.sql.Timestamp; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ActivityLogService { + + private final ActivityLogRepository activityLogRepository; + private final AuthService authService; + + public void saveActivity(Authentication authentication, String action, String endpoint) { + ActivityLog activityLog = new ActivityLog(); + + User user = authService.authenticate(authentication); + activityLog.setUser(user); + activityLog.setAction(getUserAction(action, endpoint)); + activityLog.setTimestamp(new Timestamp(System.currentTimeMillis()).toLocalDateTime()); + + activityLogRepository.save(activityLog); + } + + private UserAction getUserAction(String action, String endpoint) { + String joinEventRegex = "^api/events/\\d+/join$"; + Pattern joinEventPattern = Pattern.compile(joinEventRegex); + + String joinChallengeRegex = "^api/challenge-completions/challenge/\\d+$"; + Pattern joinChallengePattern = Pattern.compile(joinChallengeRegex); + + if(Objects.equals(action, "POST") && joinEventPattern.matcher(endpoint).matches()) { + return UserAction.JOIN_EVENT; + } else if(Objects.equals(action, "POST") && joinChallengePattern.matcher(endpoint).matches()) { + return UserAction.JOIN_CHALLENGE; + } else if (Objects.equals(action, "POST") && Objects.equals(endpoint, "api/challenges")) { + return UserAction.CREATE_CHALLENGE; + } else if (Objects.equals(action, "POST") && Objects.equals(endpoint, "api/events")) { + return UserAction.CREATE_EVENT; + } else { + return UserAction.OTHER; + } + + } + + public ResponseEntity getActiveUsers(Authentication authentication, + String startDate, + String endDate, + Period period, + Status userStatus, + Gender gender, + UserAction userAction) { + User user = authService.authenticate(authentication); + if(!user.isAdmin()) { + throw new UnauthorizedException("You do not have permission to get users statistics"); + } + + LocalDateTime start = LocalDate.parse(startDate).atStartOfDay(); + LocalDateTime end = LocalDate.parse(endDate).atTime(23, 59, 59); + + List activityLogs = activityLogRepository.findActiveUsers( + start, + end, + userStatus, + gender, + userAction + ); + + int totalActiveUsersCount = (int) activityLogs.stream() + .map(ActivityLog::getUser) + .distinct() + .count(); + + Map> activeUsersByPeriod = new TreeMap<>(); + + for (ActivityLog log : activityLogs) { + LocalDateTime timestamp = log.getTimestamp(); + Long userId = log.getUser().getId(); + + + LocalDateTime periodStart = getPeriodStart(timestamp, period); + + activeUsersByPeriod + .computeIfAbsent(Timestamp.valueOf(periodStart.toLocalDate().atStartOfDay()), k -> new HashSet<>()) + .add(userId); + } + + Map activeUsersCountByPeriod = activeUsersByPeriod.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().size() + )); + + ActiveUsersDto activeUsersDto = new ActiveUsersDto(); + activeUsersDto.setTotalActiveUsers(totalActiveUsersCount); + activeUsersDto.setActiveUsersPerPeriod(activeUsersCountByPeriod); + + + return ResponseEntity.ok(activeUsersDto); + } + + private LocalDateTime getPeriodStart(LocalDateTime timestamp, Period period) { + return switch (period) { + case DAY -> timestamp.toLocalDate().atStartOfDay(); + case WEEK -> timestamp.with(DayOfWeek.MONDAY).toLocalDate().atStartOfDay(); + case MONTH -> timestamp.withDayOfMonth(1).toLocalDate().atStartOfDay(); + case YEAR -> timestamp.withDayOfYear(1).toLocalDate().atStartOfDay(); + }; + } +} diff --git a/src/main/java/org/blueline/api/service/AuthService.java b/src/main/java/org/blueline/api/service/AuthService.java index 315ad9b..2295598 100644 --- a/src/main/java/org/blueline/api/service/AuthService.java +++ b/src/main/java/org/blueline/api/service/AuthService.java @@ -25,6 +25,17 @@ public String login(LoginDto loginDto) { throw new UnauthorizedException("Invalid login credentials"); } + public String loginAdmin(LoginDto loginDto) { + User user = userRepository.findByEmail(loginDto.getEmail()); + if(user != null && !user.isAdmin()) { + throw new UnauthorizedException("You do not have permission get all users"); + } + if (user != null && passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) { + return jwtService.generateToken(user); + } + throw new UnauthorizedException("Invalid login credentials"); + } + public User authenticate(Authentication authentication) { return userRepository.findById(jwtService.getUserIdFromToken(authentication.getPrincipal().toString())).orElseThrow(() -> new UnauthorizedException("Invalid token")); } diff --git a/src/main/resources/db/migrations/0010-create-activity-logs-table.sql b/src/main/resources/db/migrations/0010-create-activity-logs-table.sql new file mode 100644 index 0000000..6a6ea25 --- /dev/null +++ b/src/main/resources/db/migrations/0010-create-activity-logs-table.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +-- changeset aurel:1738433636225-1 +CREATE TABLE activity_logs (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(255) NOT NULL, timestamp TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, user_id BIGINT NOT NULL, CONSTRAINT "activity_logsPK" PRIMARY KEY (id)); + +-- changeset aurel:1738433636225-2 +ALTER TABLE activity_logs ADD CONSTRAINT "FK5bm1lt4f4eevt8lv2517soakd" FOREIGN KEY (user_id) REFERENCES users (id); + diff --git a/src/main/resources/db/migrations/0011-increase-avatar-field-length.sql b/src/main/resources/db/migrations/0011-increase-avatar-field-length.sql new file mode 100644 index 0000000..ac9a6f6 --- /dev/null +++ b/src/main/resources/db/migrations/0011-increase-avatar-field-length.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql + +-- changeset aurel:1738451427408-1 +ALTER TABLE users ALTER COLUMN avatar TYPE VARCHAR(750) USING (avatar::VARCHAR(750)); + diff --git a/src/main/resources/db/migrations/db.changelog-master.yaml b/src/main/resources/db/migrations/db.changelog-master.yaml index 9d32829..176d7c9 100644 --- a/src/main/resources/db/migrations/db.changelog-master.yaml +++ b/src/main/resources/db/migrations/db.changelog-master.yaml @@ -87,4 +87,26 @@ databaseChangeLog: relativeToChangelogFile: true encoding: UTF-8 splitStatements: true + stripComments: true + + - changeSet: + id: 0010-create-activity-logs-table + author: Aurélien + changes: + - sqlFile: + path: 0010-create-activity-logs-table.sql + relativeToChangelogFile: true + encoding: UTF-8 + splitStatements: true + stripComments: true + + - changeSet: + id: 0011-increase-avatar-field-length + author: Aurélien + changes: + - sqlFile: + path: 0011-increase-avatar-field-length.sql + relativeToChangelogFile: true + encoding: UTF-8 + splitStatements: true stripComments: true \ No newline at end of file diff --git a/target/classes/org/blueline/api/config/WebConfig$1.class b/target/classes/org/blueline/api/config/WebConfig$1.class index a3922f4..4116f59 100644 Binary files a/target/classes/org/blueline/api/config/WebConfig$1.class and b/target/classes/org/blueline/api/config/WebConfig$1.class differ diff --git a/target/classes/org/blueline/api/config/WebConfig.class b/target/classes/org/blueline/api/config/WebConfig.class index d9d1950..0d3e895 100644 Binary files a/target/classes/org/blueline/api/config/WebConfig.class and b/target/classes/org/blueline/api/config/WebConfig.class differ diff --git a/target/classes/org/blueline/api/controller/UserController.class b/target/classes/org/blueline/api/controller/UserController.class index b401dd3..7aad3f5 100644 Binary files a/target/classes/org/blueline/api/controller/UserController.class and b/target/classes/org/blueline/api/controller/UserController.class differ diff --git a/target/classes/org/blueline/api/model/User.class b/target/classes/org/blueline/api/model/User.class index 98fa613..62ccdb5 100644 Binary files a/target/classes/org/blueline/api/model/User.class and b/target/classes/org/blueline/api/model/User.class differ diff --git a/target/classes/org/blueline/api/service/AuthService.class b/target/classes/org/blueline/api/service/AuthService.class index 24cb67d..56da77e 100644 Binary files a/target/classes/org/blueline/api/service/AuthService.class and b/target/classes/org/blueline/api/service/AuthService.class differ diff --git a/target/classes/org/blueline/api/service/UserService.class b/target/classes/org/blueline/api/service/UserService.class index 7bd6652..ce1e473 100644 Binary files a/target/classes/org/blueline/api/service/UserService.class and b/target/classes/org/blueline/api/service/UserService.class differ