roles) {
+ if (isUserAuthenticated()) {
+ throw new IllegalStateException("Cannot register new user while authenticated.");
+ }
+ if (userRepository.findByUsername(req.username()).isPresent()) {
+ throw new UsernameTakenException(req.username());
+ }
+ if (userRepository.findByEmail(req.email()).isPresent()) {
+ throw new UsernameTakenException(req.email());
+ }
+ User user = convertRequestToUser(req);
+ user.setRoles(roles != null ? roles : List.of(User.Role.USER));
+ User savedUser = userRepository.save(user);
+
+ return userTokenProvider.generateRecoveryTokens(savedUser.getId());
+ }
+
+
+ /**
+ * Registers a new user with default roles (self-registration).
+ *
+ * - First user is assigned ADMIN and USER roles.
+ *
+ * @param req the user registration request DTO
+ * @return {@link UserRegisterResponse} containing recovery tokens
+ * @throws UsernameTakenException if the username is already taken
+ */
+ @Transactional
+ List addUser(UserRegisterRequest req) {
+ boolean isFirstUser = userRepository.count() == 0;
+ List roles = isFirstUser
+ ? List.of(User.Role.ADMIN, User.Role.USER)
+ : List.of(User.Role.USER);
+ return addUser(req, roles);
+ }
+
+ /**
+ * Retrieves the authenticated user
+ *
+ * @return authenticated {@link User} entity
+ * @throws AuthenticationCredentialsNotFoundException if the user is not found
+ * @throws UserDisabledException if the user is disabled
+ */
+ private User getCurrentUser() {
+ Long userId = getCurrentUserId();
+ User user = userRepository.findById(userId).orElse(null);
+ if (user == null) {
+ throw new AuthenticationCredentialsNotFoundException(
+ "User with ID " + userId + " not found.");
+ } else if (!user.isEnabled()) {
+ throw new UserDisabledException(userId);
+ }
+ return user;
+ }
+
+ /**
+ * Retrieves the profile of the authenticated user.
+ *
+ * @return {@link UserResponse} containing user profile information
+ */
+ UserResponse getCurrentUserProfile() {
+ return convertUserToResponse(getCurrentUser());
+ }
+
+ /**
+ * Retrieves a user by ID.
+ *
+ * @param userId the user ID
+ * @return {@link User} entity
+ * @throws AuthenticationCredentialsNotFoundException if the user is not found
+ */
+ private User getUserById(Long userId) {
+ return userRepository.findById(userId)
+ .orElseThrow(() -> new AuthenticationCredentialsNotFoundException(
+ "User with ID " + userId + " not found."));
+ }
+
+ /**
+ * Retrieves a user profile by ID (admin only).
+ *
+ * @param userId the user ID
+ * @return {@link UserResponse} containing user profile information
+ */
+ UserResponse getUserProfileById(Long userId) {
+ return convertUserToResponse(getUserById(userId));
+ }
+
+ /**
+ * Retrieves all user profiles (admin only, cached, paginated).
+ *
+ * @param pageable the pagination information
+ * @return a page of {@link UserResponse} containing user profile information
+ */
+ @Cacheable(
+ value = "allUsersCache",
+ key = "'allUsers:' + #pageable.pageNumber + ':' + #pageable.pageSize + ':' + #pageable.sort"
+ )
+ public Page getAllUserProfiles(Pageable pageable) {
+ return convertUsersToResponses(userRepository.findAll(pageable));
+ }
+
+ /**
+ * Disables a user by ID.
+ *
+ * @param userId the user ID
+ * @throws UserDisabledException if the user is already disabled
+ * @throws AuthenticationCredentialsNotFoundException if the user is not found
+ */
+ void deleteUserById(Long userId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new AuthenticationCredentialsNotFoundException(
+ "User with ID " + userId + " not found."));
+ if (!user.isEnabled()) {
+ throw new UserDisabledException(userId);
+ }
+ user.setEnabled(false);
+ userRepository.save(user);
+ }
+
+ /**
+ * Disables the authenticated user.
+ *
+ */
+ void deleteCurrentUser() {
+ deleteUserById(getCurrentUserId());
+ }
+
+ /**
+ * Resets the user's password using a recovery token.
+ *
+ * Validates the recovery token and ensures it belongs to the provided email. If valid,
+ * updates the user's password and invalidates the used token.
+ *
+ * @param req the user recovery request DTO containing email, recovery code, and new password
+ * @throws AuthenticationCredentialsNotFoundException if the user is not found
+ * @throws UserDisabledException if the user is disabled
+ * @throws BadCredentialsException if the recovery code is invalid
+ */
+ @Transactional
+ void resetPassword(UserRecoveryRequest req) {
+ User user = userRepository.findByEmail(req.email()).orElseThrow(
+ () -> new AuthenticationCredentialsNotFoundException(
+ "User with email " + req.email() + " not found."));
+ if (!user.isEnabled()) {
+ throw new UserDisabledException(user.getId());
+ }
+ if (!userTokenProvider.isTokenValidForUserId(req.recoveryCode(), user.getId())) {
+ throw new BadCredentialsException("Invalid recovery code.");
+ }
+
+ user.setPassword(passwordEncoder.encode(req.newPassword()));
+ userRepository.save(user);
+ }
+
+ /**
+ * Updates the authenticated user's account settings.
+ *
+ * @param req the user update request DTO containing fields to update
+ * @return {@link UserResponse} containing updated user profile information
+ * @throws IllegalStateException if no authenticated user is found
+ * @throws AuthenticationCredentialsNotFoundException if the user is not found
+ * @throws UserDisabledException if the user is disabled
+ */
+ @Transactional
+ UserResponse updateCurrentUser(UserUpdateRequest req) {
+ Long userId = getCurrentUser().getId();
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new AuthenticationCredentialsNotFoundException(
+ "User with ID " + userId + " not found."));
+ if (!user.isEnabled()) {
+ throw new UserDisabledException(userId);
+ }
+ if (req.fullName() != null && !req.fullName().isBlank()) {
+ user.setFullName(req.fullName());
+ }
+ if (req.email() != null && !req.email().isBlank()) {
+ user.setEmail(req.email());
+ }
+ if (req.phoneNumber() != null) {
+ user.setPhoneNumber(req.phoneNumber());
+ }
+ if (req.address() != null) {
+ user.setAddress(req.address());
+ }
+ if (req.profilePictureUrl() != null) {
+ user.setProfilePictureUrl(req.profilePictureUrl());
+ }
+ if (req.preferredLanguage() != null && !req.preferredLanguage().isBlank()) {
+ user.setPreferredLanguage(req.preferredLanguage());
+ }
+ if (req.settings() != null) {
+ user.setSettings(req.settings());
+ }
+ userRepository.save(user);
+ return convertUserToResponse(user);
+ }
+
+ /**
+ * Refreshes access and refresh tokens using a valid refresh token.
+ *
+ * @param req the user refresh request DTO containing the refresh token
+ * @return a list of {@link TokenResponse} containing new access and refresh tokens
+ * @throws BadCredentialsException if the refresh token does not belong to the authenticated user
+ */
+ List refreshTokens(UserRefreshRequest req) {
+ User user = getCurrentUser();
+
+ Map claims = userTokenProvider.validateTokenAndRetrieveClaims(
+ req.refreshToken());
+ String tokenUsername = claims.get("username");
+
+ if (!user.getUsername().equals(tokenUsername)) {
+ throw new BadCredentialsException("Refresh token does not belong to authenticated user");
+ }
+ return userTokenProvider.generateAccessTokens(new UserTokenPayload(
+ user.getUsername(),
+ user.getEmail(),
+ user.getRoles().stream().map(Enum::name).toList(),
+ user.getPreferredLanguage(),
+ user.getId()
+ ));
+ }
+
+ /**
+ * Get short-lived RS256 signed JWT token for MQTT authentication.
+ *
+ * @return the MQTT authentication response containing the JWT token
+ * @throws AuthenticationCredentialsNotFoundException if the user is not found
+ */
+ UserMqttResponse getMqttAuthToken() throws AuthenticationCredentialsNotFoundException {
+ User user = getCurrentUser();
+ List topics = userDeviceTopicProvider.getTopicsForUser(user.getId());
+ log.debug("Retrieved {} topics for user {}: {}", topics.size(), user.getId(), topics);
+ return new UserMqttResponse(user.getId(),
+ jwtUtil.generateMqttToken(new UserMqttTokenPayload(user.getId(), topics)));
+ }
+
+ /**
+ * Links a device to the currently authenticated user.
+ */
+ void linkDeviceToCurrentUser(DeviceLinkRequest req) {
+ deviceLinkProvider.linkDevice(req, getCurrentUserId());
+ }
+
+ /*
+ * Unlink a device from the currently authenticated user.
+ */
+ void unlinkDeviceFromCurrentUser(DeviceLinkRequest req) {
+ deviceLinkProvider.unlinkDevice(req, getCurrentUserId());
+ }
+
+ /*
+ * Retrieves devices linked to the currently authenticated user.
+ */
+ List getDevicesForCurrentUser() {
+ return userDeviceProvider.getUserDevices(getCurrentUserId());
+ }
+
+ /*====== REDIS STATE ======*/
+
+ /**
+ * Updates the currently authenticated user's last online value.
+ *
+ * @param username the username of the user to update the last online timestamp for
+ */
+ public void updateLastOnline(String username) {
+ redisTemplate.opsForValue()
+ .set("user:lastOnline:" + username, String.valueOf(System.currentTimeMillis()));
+ }
+
+ @ApplicationModuleListener
+ void on(UserUpdateLastOnlineEvent e) {
+ updateLastOnline(e.username());
+ }
+
+ /**
+ * Retrieves the currently authenticated user's last online value.
+ *
+ * @param username the username of the user to retrieve the last online timestamp for
+ * @return the last online timestamp in milliseconds, or null if not found
+ */
+ public Long getLastOnline(String username) {
+ String lastOnlineStr = (String) redisTemplate.opsForValue().get("user:lastOnline:" + username);
+
+ if (lastOnlineStr != null) {
+ return Long.parseLong(lastOnlineStr);
+ }
+ return null;
+ }
+
+ public boolean isOnline(String username) {
+ Long lastOnline = getLastOnline(username);
+ if (lastOnline == null) {
+ return false;
+ }
+ long currentTime = System.currentTimeMillis();
+ return (currentTime - lastOnline) <= ONLINE_THRESHOLD_MS;
+ }
+
+ /*====== HELPERS ======*/
+
+ /**
+ * Checks if a user is authenticated in the security context.
+ *
+ * @return true if a user is authenticated, false otherwise
+ */
+ public boolean isUserAuthenticated() {
+ SecurityContext context = SecurityContextHolder.getContext();
+ var auth = context.getAuthentication();
+ return auth != null && auth.isAuthenticated()
+ && !(auth instanceof AnonymousAuthenticationToken);
+ }
+
+ public Long getCurrentUserId() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()
+ || authentication instanceof AnonymousAuthenticationToken) {
+ throw new AuthenticationCredentialsNotFoundException("No authenticated user found.");
+ }
+ return Long.parseLong(authentication.getName());
+ }
+
+ /**
+ * Converts a UserRegisterRequest DTO to a User entity.
+ *
+ * @param req the user registration request DTO
+ * @return the user entity
+ */
+ public User convertRequestToUser(UserRegisterRequest req) {
+ String encodedPassword = passwordEncoder.encode(req.password());
+ User user = new User();
+ user.setUsername(req.username());
+ user.setPassword(encodedPassword);
+ user.setEmail(req.email());
+ user.setFullName(req.fullName());
+ user.setPreferredLanguage(req.preferredLanguage());
+ return user;
+ }
+
+ /**
+ * Converts a User entity to a UserResponse DTO.
+ *
+ * @param user the user entity
+ * @return the user response DTO
+ */
+ public UserResponse convertUserToResponse(User user) {
+ if (user == null) {
+ return null;
+ }
+ List roleList = user.getRoles().stream()
+ .map(Enum::name)
+ .toList();
+
+ return new UserResponse(
+ user.getId(), user.getUsername(), user.getFullName(), user.getEmail(),
+ user.getProfilePictureUrl(),
+ user.getPhoneNumber(), user.getAddress(), user.getCreatedAt(), user.getUpdatedAt(),
+ roleList, user.getPreferredLanguage(), user.getSettings()
+ );
+ }
+
+ /**
+ * Converts a list of User entities to a list of UserResponse DTOs.
+ *
+ * @param users the list of user entities
+ * @return the list of user response DTOs
+ */
+ public Page convertUsersToResponses(Page users) {
+ return users.map(this::convertUserToResponse);
+ }
+}
diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/DeviceDtoUtil.java b/src/main/java/dev/ivfrost/hydro_backend/util/DeviceDtoUtil.java
deleted file mode 100644
index d5c5ad9..0000000
--- a/src/main/java/dev/ivfrost/hydro_backend/util/DeviceDtoUtil.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package dev.ivfrost.hydro_backend.util;
-
-import dev.ivfrost.hydro_backend.dto.DeviceResponse;
-import dev.ivfrost.hydro_backend.entity.Device;
-
-import java.util.Comparator;
-import java.util.List;
-import java.util.stream.Collectors;
-
-public class DeviceDtoUtil {
-
- /**
- * Converts a Device entity to a DeviceResponse DTO.
- *
- * @param device the device entity
- * @return the device response DTO
- */
- public static DeviceResponse convertDeviceToResponse(Device device) {
- if (device == null) return null;
- return new DeviceResponse(
- device.getId(),
- device.getName(),
- device.getLocation(),
- device.getFirmware(),
- device.getTechnicalName(),
- device.getIp(),
- device.getCreatedAt(),
- device.getUpdatedAt(),
- device.getLinkedAt(),
- device.getLastSeen(),
- device.getUser(),
- device.getDisplayOrder() != null ? device.getDisplayOrder() : 0
- );
- }
-
- /**
- * Converts a list of Device entities to a list of DeviceResponse DTOs.
- *
- * @param devices the list of device entities
- * @return the list of device response DTOs
- */
- public static List convertDevicesToResponse(List devices) {
- return devices.stream()
- .map(DeviceDtoUtil::convertDeviceToResponse)
- .collect(Collectors.toList());
- }
-}
-
diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/RateLimitUtils.java b/src/main/java/dev/ivfrost/hydro_backend/util/RateLimitUtils.java
deleted file mode 100644
index 91f7d05..0000000
--- a/src/main/java/dev/ivfrost/hydro_backend/util/RateLimitUtils.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package dev.ivfrost.hydro_backend.util;
-
-import dev.ivfrost.hydro_backend.entity.User;
-import io.github.bucket4j.Bandwidth;
-import io.github.bucket4j.Bucket;
-import jakarta.servlet.http.HttpServletRequest;
-import lombok.AllArgsConstructor;
-import lombok.NoArgsConstructor;
-import org.springframework.stereotype.Component;
-
-import java.time.Duration;
-import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
-
-@Component
-@AllArgsConstructor
-@NoArgsConstructor
-public class RateLimitUtils {
-
- private ConcurrentHashMap buckets = new ConcurrentHashMap<>();
-
- public Optional getBucketByUserOrIp(User user, String ipAddress) {
- if (user != null) {
- return Optional.of(getBucketByUserId(user.getId().toString()));
- } else if (ipAddress != null && !ipAddress.isEmpty()) {
- return Optional.of(getBucketByIp(ipAddress));
- } else {
- return Optional.empty();
- }
- }
-
- private Bucket getBucketByIp(String ipAddres) {
- return buckets.computeIfAbsent(ipAddres, k -> {
- Bandwidth limit = Bandwidth.builder()
- .capacity(10)
- .refillGreedy(10, Duration.ofMinutes(1))
- .build();
- return Bucket.builder().addLimit(limit).build();
- });
- }
-
- private Bucket getBucketByUserId(String userId) {
- return buckets.computeIfAbsent(userId, k -> {
- Bandwidth limit = Bandwidth.builder()
- .capacity(20)
- .refillGreedy(20, Duration.ofMinutes(1))
- .build();
- return Bucket.builder().addLimit(limit).build();
- });
- }
-
- public static String extractClientIp(HttpServletRequest request) {
- String xff = request.getHeader("X-Forwarded-For");
- if (xff != null && !xff.isEmpty()) {
- return xff.split(",")[0].trim();
- }
- return request.getRemoteAddr();
- }
-}
\ No newline at end of file
diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/RecoveryCodeUtil.java b/src/main/java/dev/ivfrost/hydro_backend/util/RecoveryCodeUtil.java
deleted file mode 100644
index 201f5f2..0000000
--- a/src/main/java/dev/ivfrost/hydro_backend/util/RecoveryCodeUtil.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package dev.ivfrost.hydro_backend.util;
-
-import dev.ivfrost.hydro_backend.repository.UserTokenRepository;
-import dev.ivfrost.hydro_backend.service.EncoderService;
-import lombok.AllArgsConstructor;
-import org.springframework.beans.factory.annotation.Value;
-
-import java.security.SecureRandom;
-
-@AllArgsConstructor
-public class RecoveryCodeUtil {
- private static final String CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
- private static final SecureRandom RANDOM = new SecureRandom();
- private static final int RECOVERY_CODE_LENGTH = 16;
-
- public static String generateRecoveryCode() {
- StringBuilder code = new StringBuilder(RECOVERY_CODE_LENGTH);
- for (int i = 0; i < RECOVERY_CODE_LENGTH; i++) {
- int idx = RANDOM.nextInt(CHARSET.length());
- code.append(CHARSET.charAt(idx));
- }
- return code.toString();
- }
-
- public static String[] generateRecoveryCodes(int count) {
- String[] codes = new String[count];
- for (int i = 0; i < count; i++) {
- codes[i] = generateRecoveryCode();
- }
- return codes;
- }
-}
diff --git a/src/main/java/dev/ivfrost/hydro_backend/util/ValidationUtils.java b/src/main/java/dev/ivfrost/hydro_backend/util/ValidationUtils.java
deleted file mode 100644
index 4c59d96..0000000
--- a/src/main/java/dev/ivfrost/hydro_backend/util/ValidationUtils.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package dev.ivfrost.hydro_backend.util;
-
-import dev.ivfrost.hydro_backend.repository.UserRepository;
-import dev.ivfrost.hydro_backend.service.UserService;
-import jakarta.validation.Validation;
-import lombok.AllArgsConstructor;
-import org.springframework.stereotype.Service;
-
-import java.util.Map;
-import java.util.stream.Collectors;
-
-@AllArgsConstructor
-@Service
-public class ValidationUtils {
-
- private final UserRepository userRepository;
-
- public Map getClassValidationRules(Class> className) {
- var validator = Validation.buildDefaultValidatorFactory().getValidator();
- var beanDescriptor = validator.getConstraintsForClass(className);
- return beanDescriptor.getConstrainedProperties()
- .stream()
- .collect(Collectors.toMap(
- prop -> prop.getPropertyName(),
- prop -> prop.getConstraintDescriptors()
- .stream()
- .map(descriptor -> Map.of(
- "annotation", descriptor.getAnnotation().annotationType().getSimpleName(),
- "attributes", descriptor.getAttributes()
- ))
- .collect(Collectors.toList())
- ));
- }
-
- public boolean isUsernameAvailable(String username) {
- return !userRepository.existsByUsername(username);
- }
-
- public boolean isEmailAvailable(String email) {
- return !userRepository.existsByEmail(email);
- }
-}
diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties
new file mode 100644
index 0000000..57c4bbf
--- /dev/null
+++ b/src/main/resources/application-dev.properties
@@ -0,0 +1,41 @@
+spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties]
+# Secrets (provided by Vault in prod)
+spring.cloud.vault.enabled=false
+jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg==
+recovery.secret=19rWp+tewwkvUJu1QGXXQ049OgaACoZi+y7UVNkGYpU=
+device.secret=yr7WkTwDYdR4FmTihsUvTRfrWi9C2IPqBUNGj98l7IQ=
+# MQTT JWT Private Key path
+mqtt.jwt.private.key.path=./secrets/mqtt_jwt_private_key.pem
+# Database settings
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://localhost:5433/hydrodb
+spring.datasource.username=admin
+spring.datasource.password=supersecret
+spring.jpa.show-sql=true
+spring.sql.init.mode=never
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.defer-datasource-initialization=true
+# Redis settings
+spring.data.redis.host=localhost
+spring.data.redis.port=6379
+spring.data.redis.password=supersecret
+spring.data.redis.ssl.enabled=false
+# Cache settings
+spring.cache.type=redis
+# Hibernate settings
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.properties.hibernate.physical_naming_strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
+spring.jpa.properties.hibernate.implicit_naming_strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
+# RabbitMQ settings
+spring.rabbitmq.host=localhost
+spring.rabbitmq.port=5672
+spring.rabbitmq.username=myuser
+spring.rabbitmq.password=supersecret
+# Modulith settings
+spring.modulith.events.republish-outstanding-events-on-restart=true
+spring.modulith.events.jdbc.schema-initialization.enabled=true
+# CORS (Vite)
+cors.allowed-origins=http://127.0.0.1:5173
+# Logging
+logging.level.org.springframework.web=DEBUG
+logging.level.dev.ivfrost.hydro_backend=DEBUG
diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties
new file mode 100644
index 0000000..9c3ec35
--- /dev/null
+++ b/src/main/resources/application-prod.properties
@@ -0,0 +1,24 @@
+spring.config.import=vault://
+spring.main.banner-mode=off
+# HashiCorp Vault (provides db credentials, jwt.secret and recovery.secret)
+spring.cloud.vault.uri=${VAULT_URI}
+spring.cloud.vault.authentication=approle
+spring.cloud.vault.kv.enabled=true
+spring.cloud.vault.kv.backend=secret
+spring.cloud.vault.kv.default-context=hydro-api
+spring.cloud.vault.app-role.role-id=${VAULT_ROLE_ID}
+spring.cloud.vault.app-role.secret-id=${VAULT_SECRET_ID}
+# Database settings
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.jpa.show-sql=false
+spring.jpa.hibernate.ddl-auto=validate
+# Proxy awareness
+server.forward-headers-strategy=framework
+# CORS (Vite)
+cors.allowed-origins=http://192.168.1.214:5173
+# Disable SwaggerUI
+springdoc.api-docs.enabled=false
+springdoc.swagger-ui.enabled=false
+#management.endpoints.health.show-details=never
+# Logging
+logging.level.org.springframework=ERROR
diff --git a/src/main/resources/application-staging.properties b/src/main/resources/application-staging.properties
new file mode 100644
index 0000000..e80175a
--- /dev/null
+++ b/src/main/resources/application-staging.properties
@@ -0,0 +1,24 @@
+spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties]
+# Secrets (provided by Vault in prod)
+spring.cloud.vault.enabled=false
+jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg==
+recovery.secret=CH45JyhYaMp/wWDD43DIfQ1RgFyGIo3IjOVkrugEw+uNmVHsODwe6bhyYxlomYta45x16FKmjmjIR8uCHuT2/w==
+device.secret=CH45JyhYaMp0Ew6ParzBnuAt3E3DIfQ1RgFyGIo3IjOVkrugEw+uFlNy/hhDVLmjEYta45x16F0EarzBnuAt3Ew==
+# Database settings
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://192.168.1.214:5433/hydrodb
+spring.datasource.username=admin
+spring.datasource.password=supersecret
+spring.jpa.hibernate.ddl-auto=update
+# Redis settings
+spring.data.redis.host=redis
+spring.data.redis.port=6379
+spring.data.redis.password=secret
+spring.cache.redis.time-to-live=10m
+# Proxy awareness
+server.forward-headers-strategy=framework
+# CORS (Vite)
+cors.allowed-origins=http://192.168.1.214:5173
+#management.endpoints.health.show-details=never
+# Logging
+logging.level.org.springframework=ERROR
diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties
new file mode 100644
index 0000000..a7ef48c
--- /dev/null
+++ b/src/main/resources/application-test.properties
@@ -0,0 +1,40 @@
+spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties]
+# Secrets (provided by Vault in prod)
+spring.cloud.vault.enabled=false
+jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg==
+mqtt.jwt.secret=XL1pqJURj0rFbdWXiHCwD0tdFlNy/hhDVLmjEl3GoKRtkjfqUYh7j7MAlsNCch0Ew6ParzBnuAt3E4bODCj5Eg==
+recovery.secret=CH45JyhYaMp/wWDD43DIfQ1RgFyGIo3IjOVkrugEw+uNmVHsODwe6bhyYxlomYta45x16FKmjmjIR8uCHuT2/w==
+device.secret=CH45JyhYaMp0Ew6ParzBnuAt3E3DIfQ1RgFyGIo3IjOVkrugEw+uFlNy/hhDVLmjEYta45x16F0EarzBnuAt3Ew==
+# Database settings
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.url=jdbc:postgresql://localhost:5433/hydrotestdb
+spring.datasource.username=admin
+spring.datasource.password=supersecret
+spring.jpa.show-sql=true
+spring.sql.init.mode=always
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.defer-datasource-initialization=true
+# Redis settings
+spring.data.redis.host=localhost
+spring.data.redis.port=6379
+spring.data.redis.password=supersecret
+spring.data.redis.ssl.enabled=false
+# Cache settings
+spring.cache.type=redis
+# Hibernate settings
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.properties.hibernate.physical_naming_strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
+spring.jpa.properties.hibernate.implicit_naming_strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyJpaCompliantImpl
+# RabbitMQ settings
+spring.rabbitmq.host=localhost
+spring.rabbitmq.port=5672
+spring.rabbitmq.username=myuser
+spring.rabbitmq.password=supersecret
+# Modulith settings
+spring.modulith.events.republish-outstanding-events-on-restart=true
+spring.modulith.events.jdbc.schema-initialization.enabled=true
+# CORS (Vite)
+cors.allowed-origins=http://127.0.0.1:5173
+# Logging
+logging.level.org.springframework.web=DEBUG
+logging.level.dev.ivfrost.hydro_backend=DEBUG
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..f40839f
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,9 @@
+spring.config.import=optional:file:../.env[.properties],optional:file:.env[.properties]
+# JWT settings
+jwt.expiration-ms=3600000
+jwt.refresh-expiration-ms=86400000
+mqtt.jwt.expiration-ms=300000
+# SwaggerUI
+springdoc.api-docs.path=/v3/api-docs
+springdoc.swagger-ui.path=/swagger-ui.html
+springdoc.swagger-ui.operationsSorter=method
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
deleted file mode 100644
index 89a28bf..0000000
--- a/src/main/resources/application.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-spring:
- application:
- name: hydro-backend
- jpa:
- hibernate:
- ddl-auto: create-drop
- datasource:
- url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
- username: ${POSTGRES_USER}
- password: ${POSTGRES_PASSWORD}
- driver-class-name: org.postgresql.Driver
-
-security-code:
- secret: ${SECURITY_CODE_SECRET} # used to store security codes in encrypted form
-jwt:
- secret: ${JWT_SECRET}
- expiration-ms: 3600000 # 1 hour
- refresh-expiration-ms: 86400000 # 24 hours
-
-device:
- secret: ${DEVICE_SECRET}
-
-springdoc:
- swagger-ui:
- path: /docs
- url: http://localhost:8080/v3/api-docs
-
-server:
- forward-headers-strategy: framework
- servlet:
- context-path: /
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
new file mode 100644
index 0000000..3b531bb
--- /dev/null
+++ b/src/main/resources/schema.sql
@@ -0,0 +1,8 @@
+ALTER TABLE IF EXISTS tokens
+ ADD CONSTRAINT fk_tokens_user
+ FOREIGN KEY (user_id) REFERENCES users (id)
+ ON DELETE CASCADE;
+
+ALTER TABLE IF EXISTS devices
+ ADD CONSTRAINT fk_devices_user
+ FOREIGN KEY (user_id) REFERENCES users (id);
\ No newline at end of file
diff --git a/src/test/java/dev/ivfrost/hydro_backend/HydroApiApplicationTests.java b/src/test/java/dev/ivfrost/hydro_backend/HydroApiApplicationTests.java
new file mode 100644
index 0000000..00ca53c
--- /dev/null
+++ b/src/test/java/dev/ivfrost/hydro_backend/HydroApiApplicationTests.java
@@ -0,0 +1,65 @@
+package dev.ivfrost.hydro_backend;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.notNullValue;
+
+import dev.ivfrost.hydro_backend.users.UserTokenProvider;
+import dev.ivfrost.hydro_backend.users.UserRegisterRequest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class HydroApiApplicationTests {
+
+ @Autowired
+ private UserTokenProvider userTokenProvider;
+
+ @LocalServerPort
+ private int port;
+
+ @BeforeAll
+ static void setupBaseUri() {
+ RestAssured.baseURI = "http://localhost";
+ }
+
+ // Test user registration endpoint
+ @Test
+ void testUserRegistration() {
+ RestAssured.port = port;
+ UserRegisterRequest request = new UserRegisterRequest(
+ "test@mail.com",
+ "testuser",
+ "Test User",
+ "password123",
+ "es");
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(request)
+ .when()
+ .post("/v1/users")
+ .then()
+ .assertThat()
+ .statusCode(HttpStatus.CREATED.value())
+ .body("message", equalTo("User registered successfully"))
+ .body("details", notNullValue())
+ .body("details", hasSize(5))
+ .body("details[0].type", equalTo("RECOVERY_CODE"))
+ .body("details[0].value", notNullValue())
+ .body("details.findAll { it.type == 'RECOVERY_CODE' }.size()", equalTo(5));
+ }
+
+ @Test
+ void contextLoads() {
+ }
+}
diff --git a/src/test/java/dev/ivfrost/hydro_backend/HydroBackendApplicationTests.java b/src/test/java/dev/ivfrost/hydro_backend/HydroBackendApplicationTests.java
deleted file mode 100644
index 772c55f..0000000
--- a/src/test/java/dev/ivfrost/hydro_backend/HydroBackendApplicationTests.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package dev.ivfrost.hydro_backend;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class HydroBackendApplicationTests {
-
- @Test
- void contextLoads() {
- }
-
-}
diff --git a/src/test/java/dev/ivfrost/hydro_backend/ModularityTests.java b/src/test/java/dev/ivfrost/hydro_backend/ModularityTests.java
new file mode 100644
index 0000000..ab114cf
--- /dev/null
+++ b/src/test/java/dev/ivfrost/hydro_backend/ModularityTests.java
@@ -0,0 +1,20 @@
+package dev.ivfrost.hydro_backend;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.modulith.core.ApplicationModules;
+import org.springframework.modulith.docs.Documenter;
+
+class ModularityTests {
+
+ ApplicationModules modules = ApplicationModules.of(HydroApiApplication.class);
+
+ @Test
+ void verifiesModularStructure() {
+ modules.verify();
+ }
+
+ @Test
+ void createModuleDocumentation() {
+ new Documenter(modules).writeDocumentation();
+ }
+}
\ No newline at end of file
diff --git a/test-build.sh b/test-build.sh
deleted file mode 100755
index ff6b166..0000000
--- a/test-build.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-docker run -p 8080:8080 --env-file .env hydro-backend:0.0.1-SNAPSHOT
-