Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/main/java/org/blueline/api/config/ActivityInterceptor.java
Original file line number Diff line number Diff line change
@@ -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;
}
}

14 changes: 14 additions & 0 deletions src/main/java/org/blueline/api/config/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ActiveUsersDto> 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
);
}

}
15 changes: 15 additions & 0 deletions src/main/java/org/blueline/api/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ public ResponseEntity<String> 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<String> loginAdmin(
@Valid @RequestBody LoginDto loginDto) {
return new ResponseEntity<>(authService.loginAdmin(loginDto), HttpStatus.OK);
}

@GetMapping("/me")
@Operation(
summary = "Get the authenticated user",
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/org/blueline/api/model/ActivityLog.java
Original file line number Diff line number Diff line change
@@ -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;

}

2 changes: 1 addition & 1 deletion src/main/java/org/blueline/api/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/org/blueline/api/model/dto/ActiveUsersDto.java
Original file line number Diff line number Diff line change
@@ -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<Timestamp, Integer> activeUsersPerPeriod;
}
8 changes: 8 additions & 0 deletions src/main/java/org/blueline/api/model/enums/Period.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.blueline.api.model.enums;

public enum Period {
DAY,
WEEK,
MONTH,
YEAR
}
9 changes: 9 additions & 0 deletions src/main/java/org/blueline/api/model/enums/UserAction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.blueline.api.model.enums;

public enum UserAction {
CREATE_EVENT,
CREATE_CHALLENGE,
JOIN_EVENT,
JOIN_CHALLENGE,
OTHER
}
Original file line number Diff line number Diff line change
@@ -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<ActivityLog, Long> {

@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<ActivityLog> findActiveUsers(
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("userStatus") Status userStatus,
@Param("gender") Gender gender,
@Param("userAction") UserAction userAction
);
}
129 changes: 129 additions & 0 deletions src/main/java/org/blueline/api/service/ActivityLogService.java
Original file line number Diff line number Diff line change
@@ -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<ActiveUsersDto> 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<ActivityLog> activityLogs = activityLogRepository.findActiveUsers(
start,
end,
userStatus,
gender,
userAction
);

int totalActiveUsersCount = (int) activityLogs.stream()
.map(ActivityLog::getUser)
.distinct()
.count();

Map<Timestamp, Set<Long>> 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<Timestamp, Integer> 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();
};
}
}
Loading