From e7a26ebd9857ee935d1551e1c335d6c5faa12b72 Mon Sep 17 00:00:00 2001 From: Bennett Date: Fri, 27 Feb 2026 06:49:54 +0300 Subject: [PATCH 01/16] Add privilege module --- .gitignore | 4 +- README.md | 63 +++++++++++++++++ compose.yaml | 20 +++--- .../auth/controllers/PrivilegeContoller.java | 7 -- .../auth/controllers/PrivilegeController.java | 15 ++++ .../auth/services/PrivilegeService.java | 69 ++++++++++++++++++- 6 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 README.md delete mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java diff --git a/.gitignore b/.gitignore index 2924416..5a8224f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ out/ http -ROADMAP \ No newline at end of file +ROADMAP + +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b64af --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Flextuma + +Flextuma is a Spring Boot application designed to handle financial transactions and integrations. It serves as a backend service for the FlexCodeLabs payment ecosystem. + +## Prerequisites + +Before running the application, ensure you have the following installed: + +* **Java 17**: This project requires JDK 17. +* **Docker & Docker Compose**: For containerization and running dependencies. +* **Gradle**: The project uses Gradle for build automation (a wrapper is provided). + +### External Dependencies + +The application relies on PostgreSQL and Redis. By default, the `compose.yaml` does not provision these services; it expects you to provide their connection details via environment variables. + +## Getting Started + +1. **Clone the repository:** + ```bash + git clone + cd flextuma + ``` + +2. **Configuration:** + Create a `.env` file in the root directory or export these variables in your shell. + + | Variable | Required | Description | Example | + | :--- | :--- | :--- | :--- | + | `SPRING_DATASOURCE_URL` | Yes | Database JDBC URL | `jdbc:postgresql://host:5432/db` | + | `SPRING_DATASOURCE_USERNAME` | Yes | Database username | `postgres` | + | `SPRING_DATASOURCE_PASSWORD` | Yes | Database password | `secret` | + | `SPRING_DATA_REDIS_HOST` | Yes | Redis hostname | `redis` | + | `SPRING_DATA_REDIS_PORT` | No | Redis port (default: 6379) | `6379` | + | `HIKARI_MAX_POOL` | No | Max JDBC pool size (default: 10) | `10` | + +3. **Build the application:** + Use the provided Gradle wrapper to build the project. + ```bash + ./gradlew clean build -x test + ``` + *(Note: `-x test` skips tests for a quicker build. Removing it runs the test suite.)* + +4. **Run with Docker Compose:** + Build and start the application container. + ```bash + docker compose up --build + ``` + *Ensure your database and Redis are reachable from the container.* + +The application will be accessible at `http://localhost:8080`. + +## Development + +To run tests: +```bash +./gradlew test +``` + +To run the application locally without Docker (requires local Postgres/Redis or port forwarding): +```bash +./gradlew bootRun +``` diff --git a/compose.yaml b/compose.yaml index ec698aa..11cf873 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,21 +6,23 @@ services: ports: - "8080:8080" restart: always + env_file: + - .env environment: - - SPRING_DATASOURCE_URL=jdbc:postgresql://main:5432/flexmalipo - - SPRING_DATASOURCE_USERNAME=postgres - - SPRING_DATASOURCE_PASSWORD=postgres + - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - SPRING_DOCKER_COMPOSE_ENABLED=false - - HIKARI_MAX_POOL=10 - - HIKARI_MIN_IDLE=5 - - HIKARI_IDLE_TIMEOUT=300000 - - HIKARI_CONN_TIMEOUT=20000 + - HIKARI_MAX_POOL=${HIKARI_MAX_POOL:-10} + - HIKARI_MIN_IDLE=${HIKARI_MIN_IDLE:-5} + - HIKARI_IDLE_TIMEOUT=${HIKARI_IDLE_TIMEOUT:-300000} + - HIKARI_CONN_TIMEOUT=${HIKARI_CONN_TIMEOUT:-20000} - SPRING_DEVTOOLS_RESTART_ENABLED=true - SPRING_JPA_HIBERNATE_DDL_AUTO=update # - SPRING_JPA_SHOW_SQL=true - SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT=org.hibernate.dialect.PostgreSQLDialect - - SPRING_DATA_REDIS_HOST=redis - - SPRING_DATA_REDIS_PORT=6379 + - SPRING_DATA_REDIS_HOST=${SPRING_DATA_REDIS_HOST} + - SPRING_DATA_REDIS_PORT=${SPRING_DATA_REDIS_PORT:-6379} volumes: - ./src:/app/src - ./build/classes/java/main:/app/extracted/WEB-INF/classes diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java deleted file mode 100644 index 8c5f8ad..0000000 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.flexcodelabs.flextuma.modules.auth.controllers; - -public class PrivilegeContoller { - private PrivilegeContoller() { - - } -} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java new file mode 100644 index 0000000..46f0c23 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java @@ -0,0 +1,15 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.web.bind.annotation.*; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.auth.Privilege; +import com.flexcodelabs.flextuma.modules.auth.services.PrivilegeService; + +@RestController +@RequestMapping("/api/" + Privilege.PLURAL) +public class PrivilegeController extends BaseController { + public PrivilegeController(PrivilegeService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java index 6e9312b..7e858d9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java @@ -1,7 +1,72 @@ package com.flexcodelabs.flextuma.modules.auth.services; -public class PrivilegeService { - private PrivilegeService() { +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import com.flexcodelabs.flextuma.core.entities.auth.Privilege; +import com.flexcodelabs.flextuma.core.repositories.PrivilegeRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrivilegeService extends BaseService { + + private final PrivilegeRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return Privilege.READ; + } + + @Override + protected String getAddPermission() { + return Privilege.ADD; + } + + @Override + protected String getUpdatePermission() { + return Privilege.UPDATE; + } + + @Override + protected String getDeletePermission() { + return Privilege.DELETE; + } + + @Override + public String getEntityPlural() { + return Privilege.NAME_PLURAL; + } + + @Override + protected String getEntitySingular() { + return Privilege.NAME_SINGULAR; + } + + @Override + public String getPropertyName() { + return Privilege.PLURAL; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void validateDelete(Privilege entity) { + if (Boolean.TRUE.equals(entity.getActive())) { + throw new IllegalStateException("You cannot delete an active privilege"); + } } } From a938506cb558248a3ba2730bccbb8336146d36dd Mon Sep 17 00:00:00 2001 From: Bennett Date: Fri, 27 Feb 2026 08:13:49 +0300 Subject: [PATCH 02/16] Add organisation module and config filters --- .../core/entities/auth/Organisation.java | 41 +++++++++++ .../flextuma/core/entities/auth/User.java | 4 ++ .../core/helpers/CurrentUserResolver.java | 32 +++++++++ .../helpers/TenantAwareSpecification.java | 68 ++++++++++++++++++ .../repositories/OrganisationRepository.java | 16 +++++ .../core/security/SecurityConfig.java | 7 +- .../flextuma/core/services/BaseService.java | 22 +++++- .../controllers/OrganisationController.java | 16 +++++ .../auth/services/OrganisationService.java | 70 +++++++++++++++++++ 9 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java new file mode 100644 index 0000000..4363807 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java @@ -0,0 +1,41 @@ +package com.flexcodelabs.flextuma.core.entities.auth; + +import com.flexcodelabs.flextuma.core.entities.base.NameEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "organisation") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Organisation extends NameEntity { + + public static final String PLURAL = "organisations"; + public static final String NAME_PLURAL = "Organisations"; + public static final String NAME_SINGULAR = "Organisation"; + public static final String READ = "READ_ORGANISATIONS"; + public static final String ADD = "ADD_ORGANISATIONS"; + public static final String DELETE = "DELETE_ORGANISATIONS"; + public static final String UPDATE = "UPDATE_ORGANISATIONS"; + + @NotBlank(message = "Phone number is required") + @Column(name = "phonenumber", nullable = false) + private String phoneNumber; + + @Column(nullable = true) + private String email; + + @Column(nullable = true) + private String address; + + @Column(nullable = true) + private String website; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java index cfb68d5..9358e04 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java @@ -89,6 +89,10 @@ public class User extends BaseEntity { @JoinTable(name = "userrole", joinColumns = @JoinColumn(name = "owner", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "role", referencedColumnName = "id")) private Set roles; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation", referencedColumnName = "id", nullable = true) + private Organisation organisation; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "creator", referencedColumnName = "id", nullable = true) @CreatedBy diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java new file mode 100644 index 0000000..4c09042 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java @@ -0,0 +1,32 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Resolves the full User entity (including organisation) from the security + * context. + * The standard Spring UserDetails only stores username + authorities — we need + * the + * DB entity to access organisation membership and other domain fields. + */ +@Component +@RequiredArgsConstructor +public class CurrentUserResolver { + + private final UserRepository userRepository; + + public Optional getCurrentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || !(auth.getPrincipal() instanceof String username)) { + return Optional.empty(); + } + return userRepository.findByUsername(username); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java new file mode 100644 index 0000000..7f8eaa5 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java @@ -0,0 +1,68 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.criteria.*; +import org.springframework.data.jpa.domain.Specification; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * A JPA Specification that mirrors the Node.js getWhere pattern: + * + * - SUPER_ADMIN / ALL authority → no restriction (sees everything) + * - User with an organisation → sees resources created by anyone in the same + * org OR by themselves + * - User without an organisation → sees only resources they created + * + * Applies only to entities that extend Owner (i.e. have a "createdBy" field). + * Entities that do NOT have createdBy (e.g. Organisation itself) are + * unaffected. + */ +public class TenantAwareSpecification implements Specification { + + @Serial + private static final long serialVersionUID = 1L; + + private static final String CREATED_BY = "createdBy"; + private static final String ORGANISATION = "organisation"; + private static final Set BYPASS_AUTHORITIES = Set.of("ALL", "SUPER_ADMIN"); + + private final transient User currentUser; + private final transient Set userAuthorities; + + public TenantAwareSpecification(User currentUser, Set userAuthorities) { + this.currentUser = currentUser; + this.userAuthorities = userAuthorities; + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { + + if (userAuthorities.stream().anyMatch(BYPASS_AUTHORITIES::contains)) { + return cb.conjunction(); + } + + try { + root.get(CREATED_BY); + } catch (IllegalArgumentException e) { + return cb.conjunction(); + } + + List predicates = new ArrayList<>(); + + predicates.add(cb.equal(root.get(CREATED_BY), currentUser)); + + Organisation organisation = currentUser.getOrganisation(); + if (organisation != null) { + Join creatorJoin = root.join(CREATED_BY, JoinType.LEFT); + predicates.add(cb.equal(creatorJoin.get(ORGANISATION), organisation)); + } + + return cb.or(predicates.toArray(new Predicate[0])); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java new file mode 100644 index 0000000..9b5b61c --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface OrganisationRepository + extends JpaRepository, JpaSpecificationExecutor { + java.util.Optional findByCode(String code); + + java.util.Optional findByActive(Boolean active); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index c405222..07a83c1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -10,7 +10,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; @@ -45,9 +45,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { try { http .csrf(csrf -> csrf - .csrfTokenRepository( - new org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) + .ignoringRequestMatchers("/api/login", "/api/webhooks/**") + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index 80557c3..5d49c0a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.helpers.GenericSpecification; +import com.flexcodelabs.flextuma.core.helpers.TenantAwareSpecification; import com.flexcodelabs.flextuma.core.security.SecurityUtils; import jakarta.persistence.EntityManager; @@ -24,6 +26,13 @@ public abstract class BaseService { @PersistenceContext protected EntityManager entityManager; + private CurrentUserResolver currentUserResolver; + + @org.springframework.beans.factory.annotation.Autowired + public void setCurrentUserResolver(CurrentUserResolver currentUserResolver) { + this.currentUserResolver = currentUserResolver; + } + protected abstract JpaRepository getRepository(); protected abstract String getReadPermission(); @@ -58,7 +67,7 @@ protected void checkPermission(String requiredPermission) { public Pagination findAllPaginated(Pageable pageable, List filter, String fields) { checkPermission(getReadPermission()); - Specification spec = (root, query, cb) -> cb.conjunction(); + Specification spec = buildTenantSpec(); if (filter != null && !filter.isEmpty()) { for (String filterStr : filter) { @@ -70,6 +79,14 @@ public Pagination findAllPaginated(Pageable pageable, List filter, St return buildPaginatedResponse(resultPage, pageable); } + @SuppressWarnings("unchecked") + private Specification buildTenantSpec() { + return currentUserResolver.getCurrentUser() + .map(user -> (Specification) new TenantAwareSpecification<>(user, + SecurityUtils.getCurrentUserAuthorities())) + .orElse((root, query, cb) -> cb.conjunction()); + } + private Pagination buildPaginatedResponse(Page resultPage, Pageable pageable) { return Pagination.builder() .page(pageable.getPageNumber() + 1) @@ -82,7 +99,8 @@ private Pagination buildPaginatedResponse(Page resultPage, Pageable pageab @Transactional(readOnly = true) public List findAll() { checkPermission(getReadPermission()); - return getRepository().findAll(); + Specification spec = buildTenantSpec(); + return getRepositoryAsExecutor().findAll(spec); } @Transactional(readOnly = true) diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java new file mode 100644 index 0000000..24e27e7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.modules.auth.services.OrganisationService; + +@RestController +@RequestMapping("/api/" + Organisation.PLURAL) +public class OrganisationController extends BaseController { + public OrganisationController(OrganisationService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java new file mode 100644 index 0000000..468be62 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java @@ -0,0 +1,70 @@ +package com.flexcodelabs.flextuma.modules.auth.services; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.repositories.OrganisationRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrganisationService extends BaseService { + + private final OrganisationRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return Organisation.READ; + } + + @Override + protected String getAddPermission() { + return Organisation.ADD; + } + + @Override + protected String getUpdatePermission() { + return Organisation.UPDATE; + } + + @Override + protected String getDeletePermission() { + return Organisation.DELETE; + } + + @Override + public String getEntityPlural() { + return Organisation.NAME_PLURAL; + } + + @Override + protected String getEntitySingular() { + return Organisation.NAME_SINGULAR; + } + + @Override + public String getPropertyName() { + return Organisation.PLURAL; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void validateDelete(Organisation entity) { + if (Boolean.TRUE.equals(entity.getActive())) { + throw new IllegalStateException("You cannot delete an active organisation"); + } + } +} From fae8efd98385252a82a921e34f3b9fc806dd7fe6 Mon Sep 17 00:00:00 2001 From: Bennett Date: Tue, 3 Mar 2026 19:10:30 +0300 Subject: [PATCH 03/16] Add feature flags --- README.md | 411 ++++++++++++++++-- build.gradle | 3 +- .../core/annotations/FeatureGate.java | 40 ++ .../core/aspects/FeatureGateAspect.java | 60 +++ .../core/entities/feature/TenantFeature.java | 52 +++ .../repositories/TenantFeatureRepository.java | 21 + .../modules/auth/services/UserService.java | 4 +- .../controllers/TenantFeatureController.java | 17 + .../services/TenantFeatureService.java | 65 +++ .../core/aspects/FeatureGateAspectTest.java | 104 +++++ .../core/services/BaseServiceTest.java | 8 +- .../TenantFeatureControllerTest.java | 39 ++ 12 files changed, 773 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java diff --git a/README.md b/README.md index e9b64af..463b177 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,380 @@ # Flextuma -Flextuma is a Spring Boot application designed to handle financial transactions and integrations. It serves as a backend service for the FlexCodeLabs payment ecosystem. +Flextuma is a configurable, multi-tenant messaging gateway built on Spring Boot. It serves multiple organisations from a single deployment with full data isolation, and supports SMS delivery today with WhatsApp and Email on the roadmap. -## Prerequisites +--- -Before running the application, ensure you have the following installed: +## Prerequisites -* **Java 17**: This project requires JDK 17. -* **Docker & Docker Compose**: For containerization and running dependencies. -* **Gradle**: The project uses Gradle for build automation (a wrapper is provided). +| Requirement | Version | +|---|---| +| Java | 17+ | +| Docker & Docker Compose | Any recent version | +| Gradle | Provided via wrapper (`./gradlew`) | -### External Dependencies +The application requires **PostgreSQL** and **Redis** to be available before startup. These are not provisioned by the included `compose.yaml` — they must be provided externally. -The application relies on PostgreSQL and Redis. By default, the `compose.yaml` does not provision these services; it expects you to provide their connection details via environment variables. +--- ## Getting Started -1. **Clone the repository:** - ```bash - git clone - cd flextuma - ``` - -2. **Configuration:** - Create a `.env` file in the root directory or export these variables in your shell. - - | Variable | Required | Description | Example | - | :--- | :--- | :--- | :--- | - | `SPRING_DATASOURCE_URL` | Yes | Database JDBC URL | `jdbc:postgresql://host:5432/db` | - | `SPRING_DATASOURCE_USERNAME` | Yes | Database username | `postgres` | - | `SPRING_DATASOURCE_PASSWORD` | Yes | Database password | `secret` | - | `SPRING_DATA_REDIS_HOST` | Yes | Redis hostname | `redis` | - | `SPRING_DATA_REDIS_PORT` | No | Redis port (default: 6379) | `6379` | - | `HIKARI_MAX_POOL` | No | Max JDBC pool size (default: 10) | `10` | - -3. **Build the application:** - Use the provided Gradle wrapper to build the project. - ```bash - ./gradlew clean build -x test - ``` - *(Note: `-x test` skips tests for a quicker build. Removing it runs the test suite.)* - -4. **Run with Docker Compose:** - Build and start the application container. - ```bash - docker compose up --build - ``` - *Ensure your database and Redis are reachable from the container.* - -The application will be accessible at `http://localhost:8080`. - -## Development - -To run tests: +### 1. Clone the repository + ```bash -./gradlew test +git clone +cd flextuma +``` + +### 2. Configure environment variables + +Create a `.env` file in the root directory or export the variables in your shell: + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SPRING_DATASOURCE_URL` | ✅ | — | JDBC URL, e.g. `jdbc:postgresql://host:5432/db` | +| `SPRING_DATASOURCE_USERNAME` | ✅ | — | Database username | +| `SPRING_DATASOURCE_PASSWORD` | ✅ | — | Database password | +| `SPRING_DATA_REDIS_HOST` | ✅ | — | Redis hostname | +| `SPRING_DATA_REDIS_PORT` | ❌ | `6379` | Redis port | +| `HIKARI_MAX_POOL` | ❌ | `10` | Max JDBC connection pool size | + +### 3. Build the application + +```bash +./gradlew clean build -x test ``` -To run the application locally without Docker (requires local Postgres/Redis or port forwarding): +### 4. Run with Docker Compose + +```bash +docker compose up --build +``` + +The application starts on **http://localhost:8080**. + +### 5. Local development (without Docker) + ```bash ./gradlew bootRun ``` + +### 6. Watch mode (live rebuild) + +```bash +./gradlew build -t +``` + +--- + +## Architecture Overview + +Flextuma follows a layered architecture with a shared `core` library and feature-based `modules`. + +``` +src/main/java/com/flexcodelabs/flextuma/ +├── core/ +│ ├── config/ # App startup, Jackson, request logging, cookie auth config +│ ├── context/ # TenantContext (ThreadLocal — reserved, not yet active) +│ ├── annotations/ # @FeatureGate — method-level feature flag annotation +│ ├── aspects/ # FeatureGateAspect — AOP enforcement of @FeatureGate +│ ├── controllers/ # BaseController — generic CRUD for all modules +│ ├── dtos/ # Pagination response wrapper +│ ├── entities/ +│ │ ├── base/ # BaseEntity, NameEntity, Owner (MappedSuperclasses) +│ │ ├── auth/ # User, Role, Privilege, Organisation +│ │ ├── connector/ # ConnectorConfig +│ │ ├── contact/ # Contact +│ │ ├── feature/ # TenantFeature — per-org feature flags +│ │ ├── metadata/ # Tag, ListEntity +│ │ └── sms/ # SmsConnector, SmsTemplate, SmsLog +│ ├── enums/ # AuthType, CategoryEnum, UserType, FilterOperator +│ ├── exceptions/ # Global exception handling +│ ├── helpers/ # Specification builder, filters, masking, template utils +│ ├── interceptors/ # Entity audit interceptor +│ ├── repositories/ # BaseRepository + all JPA repositories +│ ├── security/ # SecurityConfig, SecurityUtils, CustomSecurityExceptionHandler +│ ├── senders/ # SmsSender interface + BeamSender, NextSmsSender +│ └── services/ # BaseService, SmsSenderRegistry, DataSeederService +└── modules/ + ├── auth/ # User, Role, Privilege, Organisation controllers & services + ├── connector/ # ConnectorConfig + DataHydratorService + ├── contact/ # Contact management + ├── feature/ # TenantFeature — per-org feature flag management + ├── metadata/ # Tags and Lists + ├── notification/ # Notification management + └── sms/ # SmsConnector, SmsTemplate controllers & services +``` + +--- + +## Core Concepts + +### BaseEntity & Inheritance Chain + +All entities extend one of: + +| Class | Adds | +|---|---| +| `BaseEntity` | `id` (UUID), `created`, `updated`, `active`, `code` | +| `NameEntity extends BaseEntity` | `name`, `description` | +| `Owner extends BaseEntity` | `createdBy` (User), `updatedBy` (User) with `@CreatedBy` audit | + +### BaseController & BaseService + +Every resource gets full CRUD for free by extending these: + +| HTTP Method | Endpoint | Action | +|---|---|---| +| `GET` | `/api/{resource}` | Paginated list with optional `filter` and `fields` params | +| `GET` | `/api/{resource}/{id}` | Get by ID | +| `POST` | `/api/{resource}` | Create | +| `PUT` | `/api/{resource}/{id}` | Update (null-safe partial update) | +| `DELETE` | `/api/{resource}/{id}` | Delete (with optional pre-delete validation) | + +**Filter syntax:** `?filter=field:OPERATOR:value` — supports `EQ`, `NE`, `LIKE`, `ILIKE`, `IN`, `GT`, `LT`. + +### Permission System + +Every resource defines permission constants (`READ_*`, `ADD_*`, `UPDATE_*`, `DELETE_*`). `BaseService` checks these against the current user's granted authorities before every operation. Users with `SUPER_ADMIN` or `ALL` bypass all checks. + +--- + +## Feature Flags + +Flextuma supports per-organisation feature flags via the `@FeatureGate` AOP annotation. This lets you gate specific capabilities per tenant without a code deploy — useful for subscription tiers, beta rollouts, or temporarily suspending access. + +### How it works + +- Annotate any service method with `@FeatureGate("FEATURE_KEY")` +- Spring AOP intercepts the call and checks the `tenantfeature` table for the calling user's organisation +- If a record with `enabled = false` exists → `403 Forbidden` is thrown before the method runs +- If **no record exists** → the feature is **allowed** (default-open: you only need records for restrictions) +- Users with no organisation (SUPER_ADMIN, system users) always bypass the check + +### Developer workflow — adding a new gated feature + +**Step 1.** Pick a `SCREAMING_SNAKE_CASE` key and annotate the service method: + +```java +// modules/notification/services/NotificationService.java +@Async +@FeatureGate("BULK_CAMPAIGN") +public void sendCampaign(Campaign campaign, String username) { + // 403 thrown here automatically if org has BULK_CAMPAIGN disabled +} +``` + +**Step 2.** Add it to the feature keys table in this README (see below). + +That's it. No DB schema changes, no config files. + +--- + +### The two-layer access model + +Feature flags and permissions work together but guard different things: + +| Layer | Enforced by | Question answered | +|---|---|---| +| **Permission** | `BaseService.checkPermission()` | Does *this user's role* allow this action? | +| **Feature flag** | `@FeatureGate` AOP | Does *this organisation's plan* include this capability? | + +```java +@FeatureGate("BULK_CAMPAIGN") // ← org-level: is this feature enabled for the tenant? +public void sendCampaign(...) { + checkPermission("SEND_BULK"); // ← user-level: does the user have the right role? + ... +} +``` + +| Scenario | Result | +|---|---| +| User lacks `SEND_BULK` role | `checkPermission()` throws 403 | +| User has role, but org is restricted | `@FeatureGate` throws 403 | +| User has role AND org has feature | ✅ Proceeds | + +--- + +### Managing flags via API + +```http +### Create a restriction (disable a feature for an org) +POST /api/tenantFeatures +Content-Type: application/json + +{ + "organisation": { "id": "" }, + "featureKey": "WHATSAPP_SEND", + "enabled": false +} + +### Re-enable (e.g. after plan upgrade) +PUT /api/tenantFeatures/ +Content-Type: application/json + +{ "enabled": true } + +### List all flags for inspection +GET /api/tenantFeatures?filter=organisation:EQ: +``` + +--- + +### Available feature keys + +Document every key here when you introduce it: + +| Key | Controls | Default | +|---|---|---| +| `BULK_CAMPAIGN` | Bulk messaging to contact lists/tags | Open | +| `WHATSAPP_SEND` | WhatsApp channel sending | Open | +| `EMAIL_SEND` | Email channel sending | Open | +| `CONNECTOR_PULL` | Fetching contacts via external connector | Open | + +> **Convention:** All features are open by default. Only create `TenantFeature` records when you need to *restrict* an org. This keeps the table minimal and the logic simple. + +--- + + +## Modules + +### Auth (`/api/users`, `/api/roles`, `/api/privileges`, `/api/organisations`) + +Manages users, roles, privilege-based RBAC, and organisation membership. + +- **`User`** — linked to an `Organisation` (one-to-many: many users per org). `UserType` enum (e.g. `SYSTEM`) identifies platform-level admins. +- **`Organisation`** — the multi-tenancy anchor. Each SACCO is one Organisation. All users of that SACCO share the same `organisationId`. +- **`Role`** → **`Privilege`** — fine-grained permission strings enforced in `BaseService`. + +### Connector (`/api/connectorConfigs`) + +Configures how Flextuma connects to each organisation's external ERP/data source. + +- **`ConnectorConfig`** — stores the base URL, endpoint, `AuthType` (`NONE`, `BASIC`, `BEARER`, `API_KEY`), credentials (masked in responses), and a **JSONPath mapping list** (`List`) stored as JSONB. +- **`DataHydratorService`** — given a `tenantId` and a `memberId`, fetches the external ERP, applies the JSONPath mappings, and returns a `Map` of system keys to values. Used to populate SMS template placeholders. + +### SMS (`/api/smsConnectors`, `/api/templates`) + +Manages SMS provider configurations and message templates. + +- **`SmsConnector`** — provider configuration (URL, API key/secret, sender ID, extra settings). One connector can be marked active at a time. +- **`SmsTemplate`** — message templates with `{placeholder}` variables, categorised by `CategoryEnum` (`PROMOTIONAL`, etc.). System templates are protected from deletion. +- **`SmsLog`** — records every sent message: recipient, content, status, provider response, error, and linked template. +- **`SmsSenderRegistry`** — selects the active `SmsConnector` from the DB, finds the matching `SmsSender` implementation by provider name, and dispatches the message. + +### SMS Providers + +Two concrete `SmsSender` implementations: + +| Provider | Class | Auth Method | +|---|---|---| +| **Beem** | `BeamSender` | API key + secret (Basic Auth header) | +| **NextSMS** | `NextSmsSender` | Stub (logs output — for local testing) | + +Adding a new provider: implement `SmsSender`, annotate with `@Service`, and set the matching `provider` string on the `SmsConnector` record. + +### Connector Module — Data Hydration Flow + +``` +Request with memberId + → ConnectorConfigRepository.findByTenantId(tenantId) + → Build URL: config.url + config.endpoint.replace("{id}", memberId) + → Apply auth headers (BEARER / API_KEY / BASIC / NONE) + → Parse JSON response with Jayway JsonPath + → Map to internal keys via FieldMapping list + → Return Map for template rendering +``` + +--- + +## Security + +### Authentication + +| Client | Method | +|---|---| +| Browser / SPA | Session-based: POST credentials to `/api/login` → receive HttpOnly `SESSION` cookie (backed by Redis) | +| API/testing | HTTP Basic Auth (`Authorization: Basic base64(user:pass)`) — also accepted for session creation | +| Webhooks / PAT | Personal Access Token (planned) | + +### CSRF + +CSRF protection uses `CookieCsrfTokenRepository` (token sent as `XSRF-TOKEN` cookie, readable by SPA). Exemptions: + +- `/api/login` — no session exists yet at this point +- `/api/webhooks/**` — reserved for PAT-authenticated provider callbacks + +### Tenant-Aware Resource Filtering + +Every paginated and list query automatically applies `TenantAwareSpecification`: + +| User | Sees | +|---|---| +| `SUPER_ADMIN` or `ALL` authority | All records (no restriction) | +| User with an Organisation | Records they created **or** records created by any member of the same organisation | +| User with no Organisation | Only their own records | +| Entities without `createdBy` (e.g. `Organisation`) | No restriction applied | + +This is enforced in `BaseService.buildTenantSpec()` — all subclass services benefit automatically. + +### Session Management + +- Sessions are stored in **Redis** (`@EnableRedisHttpSession`) +- Session cookie: `SESSION`, HttpOnly, `SameSite=Lax` +- Maximum **1 concurrent session** per user + +--- + +## Data Seeding + +On startup, `DataInitializer` runs `DataSeederService.seedSystemData()`, which executes `seed.sql` via JDBC to ensure system-level data (privileges, default roles, system user) is present before the application accepts requests. + +--- + +## Development Guide + +### Running tests + +```bash +./gradlew test +``` + +### API testing (`.http` files) + +HTTP request files are in the `/http` directory. Use IntelliJ's HTTP client or any compatible tool. The login endpoint does not require a CSRF token. All subsequent mutating requests (`POST`/`PUT`/`DELETE`) must include the `X-CSRF-TOKEN` header (value from the `XSRF-TOKEN` response cookie). + +```http +### Login +POST http://localhost:8080/api/login +Content-Type: application/json + +{"username": "admin", "password": "pass"} +``` + +### Adding a new module + +1. Create an entity in `core/entities/` extending `BaseEntity`, `NameEntity`, or `Owner` +2. Define permission constants (`READ_*`, `ADD_*`, etc.) on the entity +3. Create a `JpaRepository` in `core/repositories/` +4. Create a `Service extends BaseService` in `modules/.../services/` +5. Create a `Controller extends BaseController` in `modules/.../controllers/` + +--- + +## Roadmap + +See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, [`ROADMAP/architecture.md`](ROADMAP/architecture.md) for the multi-channel notification architecture, and [`ROADMAP/roadmap-audit.md`](ROADMAP/roadmap-audit.md) for the current implementation status of each item. + +**Recently completed:** +- [x] Per-organisation feature flagging via `@FeatureGate` AOP annotation +- [x] `TenantAwareSpecification` — automatic org-scoped data isolation +- [x] `DataHydratorService` — external ERP integration with JSONPath field mapping +- [x] Template placeholder engine (`{{variable}}` syntax with missing-variable detection) + +**Immediate next steps:** +- [ ] Async SMS dispatch worker (`@Scheduled` + `SmsLog` status lifecycle) +- [ ] SMS segment calculator (GSM-7 vs Unicode encoding) +- [ ] Wallet & ledger system with pre-flight balance checks +- [ ] Personal Access Token (PAT) entity and filter for API / gateway access +- [ ] DLR webhook receiver for delivery report tracking +- [ ] Bulk campaign entity + contact list dispatch diff --git a/build.gradle b/build.gradle index d91cadb..4d3ac70 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.aspectj:aspectjweaver' implementation 'org.glassfish:jakarta.el:4.0.2' implementation 'com.jayway.jsonpath:json-path' compileOnly 'org.projectlombok:lombok' @@ -69,7 +70,7 @@ sonarqube { properties { property "sonar.projectKey", "flexcodelabs_flextuma" property "sonar.organization", "flexcodelabs" - property "sonar.host.url", "https://sonarcloud.io" + property "sonar.host.url", "https://sonar.flexcodelabs.com" property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml" } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java b/src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java new file mode 100644 index 0000000..b6cc658 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java @@ -0,0 +1,40 @@ +package com.flexcodelabs.flextuma.core.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method as requiring a specific feature to be enabled for the calling + * user's organisation. If the feature is disabled for the organisation, a 403 + * Forbidden is thrown. + * + *

+ * Default behaviour: if no + * {@link com.flexcodelabs.flextuma.core.entities.feature.TenantFeature} + * record exists for the organisation + key, the feature is + * allowed. + * Only explicit {@code enabled = false} records block access. + * + *

+ * Users without an organisation (e.g. SUPER_ADMIN / system users) always bypass + * the check. + * + *

+ * Usage: + * + *

+ * {@literal @}FeatureGate("BULK_CAMPAIGN")
+ * public void sendCampaign(...) { ... }
+ * 
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FeatureGate { + /** + * The feature key that must be enabled, e.g. {@code "BULK_CAMPAIGN"}, + * {@code "WHATSAPP_SEND"}. + */ + String value(); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java b/src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java new file mode 100644 index 0000000..0e40f57 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java @@ -0,0 +1,60 @@ +package com.flexcodelabs.flextuma.core.aspects; + +import com.flexcodelabs.flextuma.core.annotations.FeatureGate; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.TenantFeatureRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class FeatureGateAspect { + + private final CurrentUserResolver currentUserResolver; + private final TenantFeatureRepository featureRepository; + + @Before("@annotation(gate)") + public void checkFeature(FeatureGate gate) { + String featureKey = gate.value(); + + Optional userOpt = currentUserResolver.getCurrentUser(); + + if (userOpt.isEmpty()) { + return; + } + + User user = userOpt.get(); + Organisation organisation = user.getOrganisation(); + + if (organisation == null) { + return; + } + + Optional feature = featureRepository.findByOrganisationAndFeatureKey(organisation, featureKey); + + if (feature.isEmpty()) { + return; + } + + if (Boolean.FALSE.equals(feature.get().getEnabled())) { + log.warn("Feature [{}] is disabled for organisation [{}]", featureKey, organisation.getId()); + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Feature [" + featureKey + "] is not enabled for your organisation"); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java new file mode 100644 index 0000000..95c9a61 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java @@ -0,0 +1,52 @@ +package com.flexcodelabs.flextuma.core.entities.feature; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "tenantfeature", uniqueConstraints = { + @UniqueConstraint(name = "unique_org_feature", columnNames = { "organisation", "featurekey" }) +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TenantFeature extends BaseEntity { + + public static final String PLURAL = "tenantFeatures"; + public static final String NAME_PLURAL = "Tenant Features"; + public static final String NAME_SINGULAR = "Tenant Feature"; + + public static final String READ = "READ_TENANT_FEATURES"; + public static final String ADD = "ADD_TENANT_FEATURES"; + public static final String DELETE = "DELETE_TENANT_FEATURES"; + public static final String UPDATE = "UPDATE_TENANT_FEATURES"; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organisation", nullable = false) + private Organisation organisation; + + @NotBlank(message = "Feature key is required") + @Column(name = "featurekey", nullable = false) + private String featureKey; + + @Column(nullable = false) + private Boolean enabled = true; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java new file mode 100644 index 0000000..d46577a --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java @@ -0,0 +1,21 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TenantFeatureRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByOrganisationAndFeatureKey(Organisation organisation, String featureKey); + + List findAllByOrganisation(Organisation organisation); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index 0e157a6..b6a3b6a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -67,14 +67,14 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { @Override protected void validateDelete(User user) { - if (user.getSystem()) { + if (Boolean.TRUE.equals(user.getSystem())) { throw new IllegalStateException("System users cannot be deleted"); } } public User login(String username, String password) { User user = repository.findByUsername(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Invalid username or password")); if (!user.validatePassword(password)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java b/src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java new file mode 100644 index 0000000..6a43cf9 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java @@ -0,0 +1,17 @@ +package com.flexcodelabs.flextuma.modules.feature.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.modules.feature.services.TenantFeatureService; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + TenantFeature.PLURAL) +public class TenantFeatureController extends BaseController { + + public TenantFeatureController(TenantFeatureService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java b/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java new file mode 100644 index 0000000..d0782e0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java @@ -0,0 +1,65 @@ +package com.flexcodelabs.flextuma.modules.feature.services; + +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.core.repositories.TenantFeatureRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TenantFeatureService extends BaseService { + + private final TenantFeatureRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected String getReadPermission() { + return TenantFeature.READ; + } + + @Override + protected String getAddPermission() { + return TenantFeature.ADD; + } + + @Override + protected String getUpdatePermission() { + return TenantFeature.UPDATE; + } + + @Override + protected String getDeletePermission() { + return TenantFeature.DELETE; + } + + @Override + public String getEntityPlural() { + return TenantFeature.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return TenantFeature.PLURAL; + } + + @Override + protected String getEntitySingular() { + return TenantFeature.NAME_SINGULAR; + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java b/src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java new file mode 100644 index 0000000..bc8b2ca --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java @@ -0,0 +1,104 @@ +package com.flexcodelabs.flextuma.core.aspects; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import com.flexcodelabs.flextuma.core.annotations.FeatureGate; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.TenantFeatureRepository; + +@ExtendWith(MockitoExtension.class) +class FeatureGateAspectTest { + + @Mock + private CurrentUserResolver currentUserResolver; + + @Mock + private TenantFeatureRepository featureRepository; + + @InjectMocks + private FeatureGateAspect aspect; + + @Mock + private FeatureGate gate; + + private Organisation organisation; + private User user; + + @BeforeEach + void setUp() { + organisation = new Organisation(); + user = new User(); + user.setOrganisation(organisation); + when(gate.value()).thenReturn("BULK_CAMPAIGN"); + } + + @Test + void checkFeature_shouldThrowForbidden_whenFeatureIsDisabled() { + TenantFeature feature = new TenantFeature(); + feature.setEnabled(false); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(featureRepository.findByOrganisationAndFeatureKey(organisation, "BULK_CAMPAIGN")) + .thenReturn(Optional.of(feature)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> aspect.checkFeature(gate)); + + assertEquals(403, ex.getStatusCode().value()); + assertTrue(ex.getReason().contains("BULK_CAMPAIGN")); + } + + @Test + void checkFeature_shouldAllow_whenFeatureIsEnabled() { + TenantFeature feature = new TenantFeature(); + feature.setEnabled(true); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(featureRepository.findByOrganisationAndFeatureKey(organisation, "BULK_CAMPAIGN")) + .thenReturn(Optional.of(feature)); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + } + + @Test + void checkFeature_shouldAllow_whenNoRecordExists() { + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(featureRepository.findByOrganisationAndFeatureKey(organisation, "BULK_CAMPAIGN")) + .thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + } + + @Test + void checkFeature_shouldBypass_whenUserHasNoOrganisation() { + User systemUser = new User(); + systemUser.setOrganisation(null); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(systemUser)); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + verifyNoInteractions(featureRepository); + } + + @Test + void checkFeature_shouldBypass_whenNoAuthenticatedUser() { + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + verifyNoInteractions(featureRepository); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java index f0aa7e5..b83017c 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import java.util.List; import java.util.Optional; import java.util.Set; @@ -47,6 +48,9 @@ class BaseServiceTest { @Mock private EntityManager entityManager; + @Mock + private CurrentUserResolver currentUserResolver; + @Mock private SecurityContext securityContext; @@ -59,6 +63,9 @@ class BaseServiceTest { void setUp() { service = new TestService(repository, specificationExecutor); service.entityManager = entityManager; + service.setCurrentUserResolver(currentUserResolver); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); securityContextHolderMock = Mockito.mockStatic(SecurityContextHolder.class); securityContextHolderMock.when(SecurityContextHolder::getContext).thenReturn(securityContext); @@ -218,7 +225,6 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { @Test void onPreUpdate_shouldReturnNewEntity_whenExceptionOccurs() { - // We need to inject a mock ObjectMapper to force an exception com.fasterxml.jackson.databind.ObjectMapper mockMapper = mock( com.fasterxml.jackson.databind.ObjectMapper.class); org.springframework.test.util.ReflectionTestUtils.setField(service, "objectMapper", mockMapper); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java new file mode 100644 index 0000000..1a273e5 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java @@ -0,0 +1,39 @@ +package com.flexcodelabs.flextuma.modules.feature.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseControllerTest; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.modules.feature.services.TenantFeatureService; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TenantFeatureControllerTest extends BaseControllerTest { + + @Mock + private TenantFeatureService service; + + @Override + protected TenantFeatureController getController() { + return new TenantFeatureController(service); + } + + @Override + protected TenantFeatureService getService() { + return service; + } + + @Override + protected TenantFeature createEntity() { + TenantFeature feature = new TenantFeature(); + feature.setFeatureKey("BULK_CAMPAIGN"); + feature.setEnabled(true); + return feature; + } + + @Override + protected String getBaseUrl() { + return "/api/" + TenantFeature.PLURAL; + } +} From c2f76f6e4be1734ce6493ecab5ac4c3412773198 Mon Sep 17 00:00:00 2001 From: Bennett Date: Tue, 3 Mar 2026 20:58:11 +0300 Subject: [PATCH 04/16] Add sms dispatch checker for deliveries --- .../flextuma/FlextumaApplication.java | 1 + .../flextuma/core/entities/sms/SmsLog.java | 13 +- .../flextuma/core/enums/SmsLogStatus.java | 8 ++ .../core/repositories/SmsLogRepository.java | 7 +- .../core/security/SecurityConfig.java | 5 +- .../controllers/NotificationController.java | 8 +- .../services/NotificationService.java | 119 +++++++++-------- .../services/SmsDispatchWorker.java | 116 +++++++++++++++++ .../NotificationControllerTest.java | 29 +++-- .../services/SmsDispatchWorkerTest.java | 122 ++++++++++++++++++ 10 files changed, 343 insertions(+), 85 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java diff --git a/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java b/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java index acecad0..fdd4949 100644 --- a/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java +++ b/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java @@ -8,6 +8,7 @@ @org.springframework.boot.persistence.autoconfigure.EntityScan(basePackages = "com.flexcodelabs.flextuma.core.entities") @org.springframework.data.jpa.repository.config.EnableJpaRepositories(basePackages = "com.flexcodelabs.flextuma.core.repositories") @org.springframework.data.jpa.repository.config.EnableJpaAuditing(auditorAwareRef = "auditorProvider") +@org.springframework.scheduling.annotation.EnableScheduling public class FlextumaApplication { public static void main(String[] args) { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java index c0841f3..b5821ac 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java @@ -3,9 +3,12 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -40,7 +43,15 @@ public class SmsLog extends Owner { private String content; @Column(columnDefinition = "TEXT", name = "status") - private String status; + @Enumerated(EnumType.STRING) + private SmsLogStatus status; + + @Column(name = "retries", nullable = false) + private int retries = 0; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "connector", nullable = true) + private SmsConnector connector; @Column(columnDefinition = "TEXT", name = "providerresponse", nullable = true) private String providerResponse; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java new file mode 100644 index 0000000..bbafbd0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java @@ -0,0 +1,8 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum SmsLogStatus { + PENDING, + PROCESSING, + SENT, + FAILED +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index 0d9d2be..c11e525 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -1,14 +1,17 @@ package com.flexcodelabs.flextuma.core.repositories; +import java.util.List; import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; @Repository -public interface SmsLogRepository extends JpaRepository, +public interface SmsLogRepository extends BaseRepository, JpaSpecificationExecutor { + + List findTop50ByStatusOrderByCreatedAsc(SmsLogStatus status); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index 07a83c1..e902464 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -10,7 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; @@ -44,9 +43,7 @@ public CookieSerializer cookieSerializer() { public SecurityFilterChain securityFilterChain(HttpSecurity http) { try { http - .csrf(csrf -> csrf - .ignoringRequestMatchers("/api/login", "/api/webhooks/**") - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() .anyRequest().authenticated()) diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index 1569c8e..d9bd8b8 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; import java.util.Map; @@ -16,13 +17,12 @@ public class NotificationController { private final NotificationService notificationService; @PostMapping("") - public ResponseEntity> sendSms( - + public ResponseEntity send( @RequestBody Map variables, java.security.Principal principal) { - notificationService.sendTemplatedSms(variables, principal.getName()); + SmsLog log = notificationService.queueTemplatedSms(variables, principal.getName()); - return ResponseEntity.ok(Map.of("message", "SMS request queued successfully")); + return ResponseEntity.ok(log); } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index b7138bc..a950dda 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -1,88 +1,85 @@ package com.flexcodelabs.flextuma.modules.notification.services; -import java.util.List; import java.util.Map; import java.util.Optional; import org.springframework.http.HttpStatus; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.helpers.TemplateUtils; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; -import com.flexcodelabs.flextuma.core.services.SmsSender; import lombok.RequiredArgsConstructor; +/** + * Handles message queuing only. Actual dispatch is done by + * {@link com.flexcodelabs.flextuma.modules.notification.services.SmsDispatchWorker}. + */ @Service @RequiredArgsConstructor public class NotificationService { - private final SmsTemplateRepository templateRepository; - private final SmsLogRepository logRepository; - private final UserRepository userRepository; - private final SmsConnectorRepository connectorRepository; - private final List smsSenders; - - @Async - public void sendTemplatedSms(Map placeholders, String username) { - - if (username == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); - } - - User currentUser = userRepository.findByUsername(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); - - String providerValue = Optional.ofNullable(placeholders.get("provider")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "SMS provider is missing")); - - String templateCode = Optional.ofNullable(placeholders.get("templateCode")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Template is missing")); - - String phoneNumber = Optional.ofNullable(placeholders.get("phoneNumber")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Phone number is missing")); - - SmsSender activeSender = smsSenders.stream() - .filter(s -> s.getProvider().equalsIgnoreCase(providerValue)) - .findFirst() - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "No SMS service implementation found for provider " + "[" + providerValue + "]")); - - SmsTemplate template = templateRepository.findByCreatedByAndCode(currentUser, templateCode) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Template not found or you don't have access to it")); - - SmsConnector connector = connectorRepository - .findByCreatedByAndProviderAndActiveTrue(currentUser, providerValue) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "No active SMS connector found for provider [" + providerValue + "]")); - - String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); - - SmsLog log = new SmsLog(); - log.setRecipient(phoneNumber); - log.setContent(finalMessage); - log.setTemplate(template); - log.setStatus("PENDING"); - log = logRepository.save(log); - - try { - String providerId = activeSender.sendSms(connector, phoneNumber, finalMessage); - log.setStatus("SENT"); - log.setProviderResponse(providerId); - } catch (Exception e) { - log.setStatus("FAILED"); - log.setError(e.getMessage()); + private final SmsTemplateRepository templateRepository; + private final SmsLogRepository logRepository; + private final UserRepository userRepository; + private final SmsConnectorRepository connectorRepository; + + /** + * Validates the request, resolves template + connector, renders the message, + * and persists a {@code PENDING} {@link SmsLog}. The dispatch worker picks it + * up asynchronously — the caller returns immediately after the log is saved. + */ + @Transactional + public SmsLog queueTemplatedSms(Map placeholders, String username) { + + if (username == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); + } + + User currentUser = userRepository.findByUsername(username) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "User not found")); + + String providerValue = Optional.ofNullable(placeholders.get("provider")) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "SMS provider is missing")); + + String templateCode = Optional.ofNullable(placeholders.get("templateCode")) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Template is missing")); + + String phoneNumber = Optional.ofNullable(placeholders.get("phoneNumber")) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Phone number is missing")); + + SmsTemplate template = templateRepository.findByCreatedByAndCode(currentUser, templateCode) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Template not found or you don't have access to it")); + + SmsConnector connector = connectorRepository + .findByCreatedByAndProviderAndActiveTrue(currentUser, providerValue) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "No active SMS connector found for provider [" + providerValue + "]")); + + String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); + + SmsLog log = new SmsLog(); + log.setRecipient(phoneNumber); + log.setContent(finalMessage); + log.setTemplate(template); + log.setConnector(connector); + log.setStatus(SmsLogStatus.PENDING); + + return logRepository.save(log); } - logRepository.save(log); - } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java new file mode 100644 index 0000000..bb204ba --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java @@ -0,0 +1,116 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.services.SmsSender; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Polls the database every 5 seconds for {@code PENDING} SMS logs and + * dispatches them + * via the appropriate {@link SmsSender}. + * + *

+ * Status lifecycle: + * + *

+ *   PENDING → PROCESSING → SENT
+ *                       ↘ PENDING  (on failure, retries < 3)
+ *                       ↘ FAILED   (on failure, retries >= 3)
+ * 
+ * + *

+ * The {@code PROCESSING} intermediate status prevents a second worker cycle + * from + * picking up a log that is already mid-send (safe for single-instance + * deployments; + * for multi-instance, pair with a distributed lock or a message broker). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsDispatchWorker { + + private static final int MAX_RETRIES = 3; + + private final SmsLogRepository logRepository; + private final List smsSenders; + + @Scheduled(fixedDelay = 5000) + @Transactional + public void dispatch() { + List pending = logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING); + + if (pending.isEmpty()) { + return; + } + + log.debug("SmsDispatchWorker: picking up {} PENDING log(s)", pending.size()); + + for (SmsLog smsLog : pending) { + markProcessing(smsLog); + send(smsLog); + } + } + + private void markProcessing(SmsLog smsLog) { + smsLog.setStatus(SmsLogStatus.PROCESSING); + logRepository.save(smsLog); + } + + private void send(SmsLog smsLog) { + SmsConnector connector = smsLog.getConnector(); + + if (connector == null) { + log.error("SmsLog [{}] has no connector — marking FAILED", smsLog.getId()); + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setError("No connector associated with this log"); + logRepository.save(smsLog); + return; + } + + SmsSender sender = smsSenders.stream() + .filter(s -> s.getProvider().equalsIgnoreCase(connector.getProvider())) + .findFirst() + .orElse(null); + + if (sender == null) { + log.error("No SmsSender implementation found for provider [{}]", connector.getProvider()); + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setError("No sender implementation for provider: " + connector.getProvider()); + logRepository.save(smsLog); + return; + } + + try { + String providerResponse = sender.sendSms(connector, smsLog.getRecipient(), smsLog.getContent()); + smsLog.setStatus(SmsLogStatus.SENT); + smsLog.setProviderResponse(providerResponse); + log.debug("SmsLog [{}] sent successfully via [{}]", smsLog.getId(), connector.getProvider()); + } catch (Exception e) { + int retries = smsLog.getRetries() + 1; + smsLog.setRetries(retries); + smsLog.setError(e.getMessage()); + + if (retries >= MAX_RETRIES) { + smsLog.setStatus(SmsLogStatus.FAILED); + log.warn("SmsLog [{}] FAILED after {} retries: {}", smsLog.getId(), retries, e.getMessage()); + } else { + smsLog.setStatus(SmsLogStatus.PENDING); + log.warn("SmsLog [{}] retry {}/{}: {}", smsLog.getId(), retries, MAX_RETRIES, e.getMessage()); + } + } + + logRepository.save(smsLog); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java index 45da594..461e45a 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java @@ -2,12 +2,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.security.Principal; import java.util.HashMap; import java.util.Map; @@ -21,6 +20,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; @ExtendWith(MockitoExtension.class) @@ -31,10 +32,7 @@ class NotificationControllerTest { @Mock private NotificationService notificationService; - @Mock - private Principal principal; - - private ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { @@ -43,19 +41,24 @@ void setUp() { } @Test - void sendSms_shouldQueueSms_whenParametersValid() throws Exception { + void send_shouldReturnSmsLog_whenParametersValid() throws Exception { Map variables = new HashMap<>(); - variables.put("phone", "1234567890"); - variables.put("code", "1234"); - // Principal mock setup if needed, but we pass it directly + variables.put("phoneNumber", "255700000000"); + variables.put("templateCode", "OTP"); + variables.put("provider", "beem"); + + SmsLog queued = new SmsLog(); + queued.setStatus(SmsLogStatus.PENDING); + queued.setRecipient("255700000000"); + + when(notificationService.queueTemplatedSms(any(), eq("testuser"))).thenReturn(queued); mockMvc.perform(post("/api/notifications") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(variables)) .principal(() -> "testuser")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("SMS request queued successfully")); - - verify(notificationService).sendTemplatedSms(any(), eq("testuser")); + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.recipient").value("255700000000")); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java new file mode 100644 index 0000000..51a5171 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java @@ -0,0 +1,122 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.services.SmsSender; + +@ExtendWith(MockitoExtension.class) +class SmsDispatchWorkerTest { + + @Mock + private SmsLogRepository logRepository; + + @Mock + private SmsSender smsSender; + + private SmsDispatchWorker worker; + + @BeforeEach + void setUp() { + worker = new SmsDispatchWorker(logRepository, List.of(smsSender)); + } + + private SmsLog pendingLog(String provider) { + SmsConnector connector = new SmsConnector(); + connector.setProvider(provider); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.PENDING); + log.setRecipient("255700000000"); + log.setContent("Test message"); + log.setConnector(connector); + log.setRetries(0); + return log; + } + + @Test + void dispatch_shouldMarkSent_whenSendSucceeds() { + SmsLog log = pendingLog("beem"); + when(smsSender.getProvider()).thenReturn("beem"); + when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + .thenReturn(List.of(log)); + when(smsSender.sendSms(any(), any(), any())).thenReturn("provider-msg-id-123"); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.SENT, log.getStatus()); + assertEquals("provider-msg-id-123", log.getProviderResponse()); + } + + @Test + void dispatch_shouldRetry_whenSendFailsAndRetriesBelow3() { + SmsLog log = pendingLog("beem"); + when(smsSender.getProvider()).thenReturn("beem"); + when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + .thenReturn(List.of(log)); + when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.PENDING, log.getStatus()); // re-queued + assertEquals(1, log.getRetries()); + } + + @Test + void dispatch_shouldMarkFailed_whenMaxRetriesReached() { + SmsLog log = pendingLog("beem"); + log.setRetries(2); // one more will exceed the limit + + when(smsSender.getProvider()).thenReturn("beem"); + when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + .thenReturn(List.of(log)); + when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.FAILED, log.getStatus()); + assertEquals(3, log.getRetries()); + } + + @Test + void dispatch_shouldMarkFailed_whenNoConnector() { + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.PENDING); + log.setConnector(null); + + when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + .thenReturn(List.of(log)); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.FAILED, log.getStatus()); + } + + @Test + void dispatch_shouldDoNothing_whenNoPendingLogs() { + when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + .thenReturn(Collections.emptyList()); + + worker.dispatch(); + + verify(logRepository, never()).save(any()); + } +} From 62d631aea5a87cf12df2b545a0d94b270efc52e8 Mon Sep 17 00:00:00 2001 From: Bennett Date: Tue, 3 Mar 2026 22:32:10 +0300 Subject: [PATCH 05/16] Add delivery webhooks and parsers --- README.md | 4 +- .../core/repositories/SmsLogRepository.java | 3 + .../{BeamSender.java => BeemSender.java} | 24 ++-- .../flextuma/core/webhooks/BeemDlrParser.java | 36 ++++++ .../flextuma/core/webhooks/DlrParser.java | 29 +++++ .../flextuma/core/webhooks/DlrResult.java | 14 ++ .../core/webhooks/NextSmsDlrParser.java | 38 ++++++ .../services/NotificationService.java | 9 -- .../services/SmsDispatchWorker.java | 21 --- .../controllers/SmsWebhookController.java | 100 +++++++++++++++ .../flextuma/core/senders/BeamSenderTest.java | 28 ++-- .../services/SmsDispatchWorkerTest.java | 4 +- .../controllers/SmsWebhookControllerTest.java | 121 ++++++++++++++++++ 13 files changed, 371 insertions(+), 60 deletions(-) rename src/main/java/com/flexcodelabs/flextuma/core/senders/{BeamSender.java => BeemSender.java} (82%) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java diff --git a/README.md b/README.md index 463b177..a38a6ab 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ src/main/java/com/flexcodelabs/flextuma/ │ ├── interceptors/ # Entity audit interceptor │ ├── repositories/ # BaseRepository + all JPA repositories │ ├── security/ # SecurityConfig, SecurityUtils, CustomSecurityExceptionHandler -│ ├── senders/ # SmsSender interface + BeamSender, NextSmsSender +│ ├── senders/ # SmsSender interface + BeemSender, NextSmsSender │ └── services/ # BaseService, SmsSenderRegistry, DataSeederService └── modules/ ├── auth/ # User, Role, Privilege, Organisation controllers & services @@ -268,7 +268,7 @@ Two concrete `SmsSender` implementations: | Provider | Class | Auth Method | |---|---|---| -| **Beem** | `BeamSender` | API key + secret (Basic Auth header) | +| **Beem** | `BeemSender` | API key + secret (Basic Auth header) | | **NextSMS** | `NextSmsSender` | Stub (logs output — for local testing) | Adding a new provider: implement `SmsSender`, annotate with `@Service`, and set the matching `provider` string on the `SmsConnector` record. diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index c11e525..7ba0cf5 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -1,6 +1,7 @@ package com.flexcodelabs.flextuma.core.repositories; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -14,4 +15,6 @@ public interface SmsLogRepository extends BaseRepository, JpaSpecificationExecutor { List findTop50ByStatusOrderByCreatedAsc(SmsLogStatus status); + + Optional findByProviderResponse(String providerResponse); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/BeamSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java similarity index 82% rename from src/main/java/com/flexcodelabs/flextuma/core/senders/BeamSender.java rename to src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java index 3679f2b..9fe458c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/BeamSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java @@ -18,17 +18,17 @@ @Slf4j @Service -public class BeamSender implements SmsSender { +public class BeemSender implements SmsSender { private final RestTemplate restTemplate; - public BeamSender(RestTemplate restTemplate) { + public BeemSender(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @Override public String getProvider() { - return "BEAM"; + return "BEEM"; } @Override @@ -44,7 +44,7 @@ public String sendSms(SmsConnector config, String to, String message) { String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes()); headers.set("Authorization", "Basic " + encodedAuth); - BeamSmsRequest requestBody = new BeamSmsRequest(); + BeemSmsRequest requestBody = new BeemSmsRequest(); requestBody.setSourceAddr(config.getSenderId()); requestBody.setMessage(message); requestBody.setScheduleTime(""); @@ -55,31 +55,31 @@ public String sendSms(SmsConnector config, String to, String message) { recipient.setRecipientId("1"); requestBody.setRecipients(Collections.singletonList(recipient)); - HttpEntity entity = new HttpEntity<>(requestBody, headers); + HttpEntity entity = new HttpEntity<>(requestBody, headers); - ResponseEntity response = restTemplate.postForEntity( + ResponseEntity response = restTemplate.postForEntity( config.getUrl(), entity, - BeamSmsResponse.class); + BeemSmsResponse.class); - BeamSmsResponse responseBody = response.getBody(); + BeemSmsResponse responseBody = response.getBody(); if (responseBody != null && !responseBody.isValid()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Beem API Error: " + responseBody.getMessage()); } - log.info("BEAM: SMS sent successfully to {}", to); + log.info("BEEM: SMS sent successfully to {}", to); return responseBody != null ? responseBody.getMessage() : "SUCCESS"; } catch (Exception e) { - log.error("BEAM Error: {}", e.getMessage()); + log.error("BEEM Error: {}", e.getMessage()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Failed to send via Beem: " + e.getMessage()); } } @Data - static class BeamSmsRequest { + static class BeemSmsRequest { @JsonProperty("source_addr") private String sourceAddr; @@ -103,7 +103,7 @@ static class Recipient { @Data @NoArgsConstructor @AllArgsConstructor - static class BeamSmsResponse { + static class BeemSmsResponse { private boolean valid; private String message; private int code; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java new file mode 100644 index 0000000..f3a37bf --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java @@ -0,0 +1,36 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; + +@Component +public class BeemDlrParser implements DlrParser { + + private static final Set DELIVERED_STATUSES = Set.of("delivered", "sent"); + private static final Set FAILED_STATUSES = Set.of("failed", "expired", "rejected", "aborted", "undelivered", + "cancelled", "deleted"); + + @Override + public String getProvider() { + return "BEEM"; + } + + @Override + public DlrResult parse(Map payload) { + String messageId = String.valueOf(payload.getOrDefault("messageID", "")); + String rawStatus = String.valueOf(payload.getOrDefault("status", "")).toLowerCase(); + + SmsLogStatus status = null; + if (DELIVERED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.SENT; + } else if (FAILED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.FAILED; + } + + return new DlrResult(messageId, status, rawStatus); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java new file mode 100644 index 0000000..e40a7e1 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java @@ -0,0 +1,29 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import java.util.Map; + +/** + * Parses a raw DLR (Delivery Report) payload from an SMS provider into a + * normalised {@link DlrResult}. + * + *

+ * Each provider sends a different JSON shape — implement this interface + * once per provider, annotate with {@code @Component}, and the + * {@link com.flexcodelabs.flextuma.modules.webhook.controllers.SmsWebhookController} + * will automatically pick it up. + */ +public interface DlrParser { + + /** + * Provider key, must match {@code SmsConnector.provider} (case-insensitive). + */ + String getProvider(); + + /** + * Parses the raw payload map into a normalised result. + * + * @param payload the deserialized JSON body from the provider's DLR callback + * @return a {@link DlrResult} with the message ID and normalised status + */ + DlrResult parse(Map payload); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java new file mode 100644 index 0000000..f9d7de9 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java @@ -0,0 +1,14 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; + +/** + * Normalised result from a DLR payload parse. + * + * @param messageId the provider-assigned message identifier (used to locate the + * SmsLog) + * @param status the mapped internal status + * @param rawStatus the raw status string from the provider (stored for audit) + */ +public record DlrResult(String messageId, SmsLogStatus status, String rawStatus) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java new file mode 100644 index 0000000..4db4fa0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java @@ -0,0 +1,38 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; + +@Component +public class NextSmsDlrParser implements DlrParser { + + private static final Set DELIVERED_STATUSES = Set.of("delivrd", "delivered", "sent"); + private static final Set FAILED_STATUSES = Set.of("failed", "undeliv", "rejectd", "expired"); + + @Override + public String getProvider() { + return "NEXT"; + } + + @Override + public DlrResult parse(Map payload) { + String messageId = String.valueOf( + payload.getOrDefault("message_id", + payload.getOrDefault("messageId", ""))); + + String rawStatus = String.valueOf(payload.getOrDefault("status", "")).toLowerCase(); + + SmsLogStatus status = null; + if (DELIVERED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.SENT; + } else if (FAILED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.FAILED; + } + + return new DlrResult(messageId, status, rawStatus); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index a950dda..0577cba 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -21,10 +21,6 @@ import lombok.RequiredArgsConstructor; -/** - * Handles message queuing only. Actual dispatch is done by - * {@link com.flexcodelabs.flextuma.modules.notification.services.SmsDispatchWorker}. - */ @Service @RequiredArgsConstructor public class NotificationService { @@ -34,11 +30,6 @@ public class NotificationService { private final UserRepository userRepository; private final SmsConnectorRepository connectorRepository; - /** - * Validates the request, resolves template + connector, renders the message, - * and persists a {@code PENDING} {@link SmsLog}. The dispatch worker picks it - * up asynchronously — the caller returns immediately after the log is saved. - */ @Transactional public SmsLog queueTemplatedSms(Map placeholders, String username) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java index bb204ba..51e60d8 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java @@ -15,27 +15,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * Polls the database every 5 seconds for {@code PENDING} SMS logs and - * dispatches them - * via the appropriate {@link SmsSender}. - * - *

- * Status lifecycle: - * - *

- *   PENDING → PROCESSING → SENT
- *                       ↘ PENDING  (on failure, retries < 3)
- *                       ↘ FAILED   (on failure, retries >= 3)
- * 
- * - *

- * The {@code PROCESSING} intermediate status prevents a second worker cycle - * from - * picking up a log that is already mid-send (safe for single-instance - * deployments; - * for multi-instance, pair with a distributed lock or a message broker). - */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java new file mode 100644 index 0000000..0b01e70 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java @@ -0,0 +1,100 @@ +package com.flexcodelabs.flextuma.modules.webhook.controllers; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.webhooks.DlrParser; +import com.flexcodelabs.flextuma.core.webhooks.DlrResult; + +import lombok.extern.slf4j.Slf4j; + +/** + * Receives Delivery Report (DLR) callbacks from SMS providers. + * + *

+ * Endpoint: {@code POST /api/webhooks/sms/{provider}/dlr} + * + *

+ * The {@code provider} path variable must match the + * {@code SmsConnector.provider} + * value (e.g. {@code beem}, {@code next}). Matching is case-insensitive. + * + *

+ * The endpoint is unauthenticated — providers POST here without a session. + * CSRF is disabled globally so no token is required. + */ +@Slf4j +@RestController +@RequestMapping("/api") +public class SmsWebhookController { + + private final SmsLogRepository logRepository; + private final List dlrParsers; + + public SmsWebhookController(SmsLogRepository logRepository, List dlrParsers) { + this.logRepository = logRepository; + this.dlrParsers = dlrParsers; + } + + @PostMapping("/{provider}") + public ResponseEntity deliveryReport( + @PathVariable String provider, + @RequestBody Map payload) { + + log.debug("DLR received from provider [{}]: {}", provider, payload); + + DlrParser parser = dlrParsers.stream() + .filter(p -> p.getProvider() != null && p.getProvider().equalsIgnoreCase(provider)) + .findFirst() + .orElse(null); + + if (parser == null) { + log.warn("No DLR parser registered for provider [{}] — ignoring (parsers={})", provider, dlrParsers.size()); + return ResponseEntity.ok().build(); + } + + DlrResult result = parser.parse(payload); + + if (result.messageId() == null || result.messageId().isBlank()) { + log.warn("DLR from [{}] missing message ID — payload: {}", provider, payload); + return ResponseEntity.ok().build(); + } + + if (result.status() == null) { + log.debug("DLR from [{}] has intermediate status [{}] — no update needed", provider, result.rawStatus()); + return ResponseEntity.ok().build(); + } + + Optional logOpt = logRepository.findByProviderResponse(result.messageId()); + + if (logOpt.isEmpty()) { + log.warn("DLR from [{}]: no SmsLog found for messageId [{}]", provider, result.messageId()); + return ResponseEntity.ok().build(); + } + + SmsLog smsLog = logOpt.get(); + + if (SmsLogStatus.SENT.equals(smsLog.getStatus()) && SmsLogStatus.FAILED.equals(result.status())) { + log.warn("DLR: ignoring FAILED update for already-SENT log [{}]", smsLog.getId()); + return ResponseEntity.ok().build(); + } + + smsLog.setStatus(result.status()); + logRepository.save(smsLog); + + log.info("DLR: SmsLog [{}] updated to [{}] via provider [{}]", smsLog.getId(), result.status(), provider); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java b/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java index 2b9daa9..2fbd083 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java @@ -18,13 +18,13 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class BeamSenderTest { +class BeemSenderTest { @Mock private RestTemplate restTemplate; @InjectMocks - private BeamSender beamSender; + private BeemSender beemSender; private SmsConnector config; @@ -38,20 +38,20 @@ void setUp() { } @Test - void getProvider_shouldReturnBeam() { - assertEquals("BEAM", beamSender.getProvider()); + void getProvider_shouldReturnBeem() { + assertEquals("BEEM", beemSender.getProvider()); } @Test void sendSms_shouldReturnSuccess_whenApiCallIsSuccessful() { - BeamSender.BeamSmsResponse responseBody = new BeamSender.BeamSmsResponse(true, "SMS sent successfully", 100); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); + BeemSender.BeemSmsResponse responseBody = new BeemSender.BeemSmsResponse(true, "SMS sent successfully", 100); + ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); when(restTemplate.postForEntity(eq(config.getUrl()), any(HttpEntity.class), - eq(BeamSender.BeamSmsResponse.class))) + eq(BeemSender.BeemSmsResponse.class))) .thenReturn(responseEntity); - String result = beamSender.sendSms(config, "255712345678", "Hello World"); + String result = beemSender.sendSms(config, "255712345678", "Hello World"); assertEquals("SMS sent successfully", result); } @@ -59,14 +59,14 @@ void sendSms_shouldReturnSuccess_whenApiCallIsSuccessful() { @Test void sendSms_shouldReturnSuccess_whenResponseIsNull() { // If body is null, it defaults to "SUCCESS" - ResponseEntity responseEntity = new ResponseEntity<>( - (BeamSender.BeamSmsResponse) null, HttpStatus.OK); + ResponseEntity responseEntity = new ResponseEntity<>( + (BeemSender.BeemSmsResponse) null, HttpStatus.OK); when(restTemplate.postForEntity(eq(config.getUrl()), any(HttpEntity.class), - eq(BeamSender.BeamSmsResponse.class))) + eq(BeemSender.BeemSmsResponse.class))) .thenReturn(responseEntity); - String result = beamSender.sendSms(config, "255712345678", "Hello World"); + String result = beemSender.sendSms(config, "255712345678", "Hello World"); assertEquals("SUCCESS", result); } @@ -74,9 +74,9 @@ void sendSms_shouldReturnSuccess_whenResponseIsNull() { @Test void sendSms_shouldThrowException_whenConnectionFails() { when(restTemplate.postForEntity(eq(config.getUrl()), any(HttpEntity.class), - eq(BeamSender.BeamSmsResponse.class))) + eq(BeemSender.BeemSmsResponse.class))) .thenThrow(new org.springframework.web.client.ResourceAccessException("Connection failed")); assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> beamSender.sendSms(config, "255712345678", "Hello World")); + () -> beemSender.sendSms(config, "255712345678", "Hello World")); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java index 51a5171..aadcefd 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java @@ -74,14 +74,14 @@ void dispatch_shouldRetry_whenSendFailsAndRetriesBelow3() { worker.dispatch(); - assertEquals(SmsLogStatus.PENDING, log.getStatus()); // re-queued + assertEquals(SmsLogStatus.PENDING, log.getStatus()); assertEquals(1, log.getRetries()); } @Test void dispatch_shouldMarkFailed_whenMaxRetriesReached() { SmsLog log = pendingLog("beem"); - log.setRetries(2); // one more will exceed the limit + log.setRetries(2); when(smsSender.getProvider()).thenReturn("beem"); when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java new file mode 100644 index 0000000..aae91c8 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java @@ -0,0 +1,121 @@ +package com.flexcodelabs.flextuma.modules.webhook.controllers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.webhooks.DlrParser; +import com.flexcodelabs.flextuma.core.webhooks.DlrResult; + +@ExtendWith(MockitoExtension.class) +class SmsWebhookControllerTest { + + @Mock + private SmsLogRepository logRepository; + + @Mock + private DlrParser dlrParser; + + private SmsWebhookController buildController() { + return new SmsWebhookController(logRepository, List.of(dlrParser)); + } + + private Map payload(String msgId, String status) { + Map map = new HashMap<>(); + map.put("messageID", msgId); + map.put("status", status); + return map; + } + + @Test + void deliveryReport_shouldUpdateToSent_whenDelivered() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-123", SmsLogStatus.SENT, "delivered")); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.SENT); + when(logRepository.findByProviderResponse("msg-123")).thenReturn(Optional.of(log)); + when(logRepository.save(any())).thenReturn(log); + + buildController().deliveryReport("beem", payload("msg-123", "delivered")); + + verify(logRepository).findByProviderResponse("msg-123"); + verify(logRepository).save(any()); + assertEquals(SmsLogStatus.SENT, log.getStatus()); + } + + @Test + void deliveryReport_shouldUpdateToFailed_whenFailed() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-789", SmsLogStatus.FAILED, "failed")); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.PROCESSING); + when(logRepository.findByProviderResponse("msg-789")).thenReturn(Optional.of(log)); + when(logRepository.save(any())).thenReturn(log); + + buildController().deliveryReport("beem", payload("msg-789", "failed")); + + verify(logRepository).save(any()); + assertEquals(SmsLogStatus.FAILED, log.getStatus()); + } + + @Test + void deliveryReport_shouldNotSave_whenAlreadySentAndFailedDlrArrives() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-456", SmsLogStatus.FAILED, "failed")); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.SENT); + lenient().when(logRepository.findByProviderResponse("msg-456")).thenReturn(Optional.of(log)); + + buildController().deliveryReport("beem", payload("msg-456", "failed")); + + verify(logRepository, never()).save(any()); + } + + @Test + void deliveryReport_shouldNotSave_whenUnknownProvider() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + + buildController().deliveryReport("unknown_provider", payload("msg-000", "delivered")); + + verify(logRepository, never()).save(any()); + } + + @Test + void deliveryReport_shouldNotSave_whenNoLogFound() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-999", SmsLogStatus.SENT, "delivered")); + + buildController().deliveryReport("beem", payload("msg-999", "delivered")); + + verify(logRepository, never()).save(any()); + } + + @Test + void deliveryReport_shouldNotSave_whenIntermediateStatus() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-001", null, "submitted")); + + buildController().deliveryReport("beem", payload("msg-001", "submitted")); + + verify(logRepository, never()).save(any()); + } +} From 8a43f3ec225399b28aff4fadfa6844756eeab5b7 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 06:56:44 +0300 Subject: [PATCH 06/16] Add wallet module and transactions ledger --- README.md | 23 +- .../core/entities/finance/Wallet.java | 31 +++ .../entities/finance/WalletTransaction.java | 40 ++++ .../flextuma/core/enums/TransactionType.java | 6 + .../core/helpers/SmsSegmentCalculator.java | 59 +++++ .../core/helpers/SmsSegmentResult.java | 4 + .../core/repositories/WalletRepository.java | 13 + .../WalletTransactionRepository.java | 9 + .../services/DataHydratorService.java | 2 +- .../finance/services/WalletService.java | 86 +++++++ .../services/NotificationService.java | 15 ++ src/main/resources/application.properties | 5 +- .../core/entities/finance/WalletTest.java | 36 +++ .../finance/WalletTransactionTest.java | 48 ++++ .../core/enums/TransactionTypeTest.java | 22 ++ .../helpers/CustomPropertyFilterTest.java | 132 ++++++++++ .../helpers/GenericSpecificationTest.java | 226 ++++++++++++++++++ .../helpers/SmsSegmentCalculatorTest.java | 49 ++++ .../services/NotificationServiceTest.java | 173 ++++++++++++++ 19 files changed, 975 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java diff --git a/README.md b/README.md index a38a6ab..e38bcf6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Create a `.env` file in the root directory or export the variables in your shell | `SPRING_DATA_REDIS_HOST` | ✅ | — | Redis hostname | | `SPRING_DATA_REDIS_PORT` | ❌ | `6379` | Redis port | | `HIKARI_MAX_POOL` | ❌ | `10` | Max JDBC connection pool size | +| `SMS_PRICE_PER_SEGMENT` | ❌ | `20.0` | Price per SMS segment (in TZS) | ### 3. Build the application @@ -370,11 +371,29 @@ See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, - [x] `TenantAwareSpecification` — automatic org-scoped data isolation - [x] `DataHydratorService` — external ERP integration with JSONPath field mapping - [x] Template placeholder engine (`{{variable}}` syntax with missing-variable detection) +- [x] SMS segment calculator (GSM-7 vs Unicode encoding) +- [x] Wallet & ledger system with pre-flight balance checks **Immediate next steps:** - [ ] Async SMS dispatch worker (`@Scheduled` + `SmsLog` status lifecycle) -- [ ] SMS segment calculator (GSM-7 vs Unicode encoding) -- [ ] Wallet & ledger system with pre-flight balance checks - [ ] Personal Access Token (PAT) entity and filter for API / gateway access - [ ] DLR webhook receiver for delivery report tracking - [ ] Bulk campaign entity + contact list dispatch + +--- + +## Wallet Management Example +The new `WalletService` handles crediting and debiting of accounts per organisation. +Currently, wallets must be topped up programmatically until an admin UI is built. + +Example of topping up an account with 100,000 TZS dynamically inside a Service: + +```java +@Autowired +private WalletService walletService; + +public void processManualTopup(User orgAdmin) { + BigDecimal amount = BigDecimal.valueOf(100000.00); + walletService.credit(orgAdmin, amount, "Manual Top Up", "REF-12345"); +} +``` diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java new file mode 100644 index 0000000..75c6d2b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java @@ -0,0 +1,31 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "wallet") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Wallet extends Owner { + + @Column(nullable = false, precision = 19, scale = 4) + private BigDecimal balance = BigDecimal.ZERO; + + @Column(nullable = false, length = 3) + private String currency = "TZS"; + + @Version + private Long version; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java new file mode 100644 index 0000000..227819b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java @@ -0,0 +1,40 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import com.flexcodelabs.flextuma.core.enums.TransactionType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "wallettransaction") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class WalletTransaction extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "wallet", referencedColumnName = "id", nullable = false, updatable = false) + private Wallet wallet; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, updatable = false) + private TransactionType type; + + @Column(nullable = false, precision = 19, scale = 4, updatable = false) + private BigDecimal amount; + + @Column(nullable = false, updatable = false, length = 500) + private String description; + + @Column(updatable = false, length = 100) + private String reference; + + @Column(name = "balance_after", nullable = false, precision = 19, scale = 4, updatable = false) + private BigDecimal balanceAfter; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java new file mode 100644 index 0000000..9726db4 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java @@ -0,0 +1,6 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum TransactionType { + CREDIT, + DEBIT +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java new file mode 100644 index 0000000..54891cf --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java @@ -0,0 +1,59 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import java.util.HashSet; +import java.util.Set; + +public class SmsSegmentCalculator { + + private SmsSegmentCalculator() { + } + + private static final Set GSM7_CHARS = new HashSet<>(); + + static { + String gsm7Chars = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\u001BÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà^{}\\[~]|€"; + for (char c : gsm7Chars.toCharArray()) { + GSM7_CHARS.add(c); + } + } + + private static final Set GSM7_EXTENDED_CHARS = new HashSet<>(); + static { + String gsm7ExtChars = "^{}\\[~]|€"; + for (char c : gsm7ExtChars.toCharArray()) { + GSM7_EXTENDED_CHARS.add(c); + } + } + + public static SmsSegmentResult calculate(String message) { + if (message == null || message.isEmpty()) { + return new SmsSegmentResult(0, true, 0); + } + + boolean isGsm7 = true; + int gsm7Length = 0; + + for (int i = 0; i < message.length(); i++) { + char c = message.charAt(i); + + if (!GSM7_CHARS.contains(c)) { + isGsm7 = false; + break; + } + gsm7Length += GSM7_EXTENDED_CHARS.contains(c) ? 2 : 1; + } + + int segments; + int finalLength; + + if (isGsm7) { + finalLength = gsm7Length; + segments = finalLength <= 160 ? 1 : (int) Math.ceil((double) finalLength / 153); + } else { + finalLength = message.length(); + segments = finalLength <= 70 ? 1 : (int) Math.ceil((double) finalLength / 67); + } + + return new SmsSegmentResult(segments, isGsm7, finalLength); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java new file mode 100644 index 0000000..1601f7b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java @@ -0,0 +1,4 @@ +package com.flexcodelabs.flextuma.core.helpers; + +public record SmsSegmentResult(int segments, boolean isGsm7, int length) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java new file mode 100644 index 0000000..6029b83 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java @@ -0,0 +1,13 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface WalletRepository extends JpaRepository { + Optional findByCreatedBy(User user); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java new file mode 100644 index 0000000..7243bd5 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java @@ -0,0 +1,9 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.finance.WalletTransaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WalletTransactionRepository extends JpaRepository { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java index 387b647..c0ead5a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java @@ -75,7 +75,7 @@ private void applyAuthentication(HttpHeaders headers, ConnectorConfig config) { case API_KEY -> headers.set("X-API-KEY", config.getToken()); case BASIC -> headers.setBasicAuth(config.getUsername(), config.getPassword()); case NONE -> { - // No auth needed + // Intentionally empty: no authentication required } default -> throw new IllegalArgumentException("Unsupported auth type: " + config.getAuthType()); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java new file mode 100644 index 0000000..75a12b0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java @@ -0,0 +1,86 @@ +package com.flexcodelabs.flextuma.modules.finance.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import com.flexcodelabs.flextuma.core.entities.finance.WalletTransaction; +import com.flexcodelabs.flextuma.core.enums.TransactionType; +import com.flexcodelabs.flextuma.core.repositories.WalletRepository; +import com.flexcodelabs.flextuma.core.repositories.WalletTransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class WalletService { + + private final WalletRepository walletRepository; + private final WalletTransactionRepository transactionRepository; + + public Wallet getOrCreateWallet(User user) { + Optional optionalWallet = walletRepository.findByCreatedBy(user); + if (optionalWallet.isPresent()) { + return optionalWallet.get(); + } + + Wallet newWallet = new Wallet(); + newWallet.setBalance(BigDecimal.ZERO); + newWallet.setCurrency("TZS"); + newWallet.setCreatedBy(user); + + return walletRepository.save(newWallet); + } + + @Transactional + public WalletTransaction debit(User user, BigDecimal amount, String description, String reference) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Debit amount must be positive"); + } + + Wallet wallet = getOrCreateWallet(user); + + if (wallet.getBalance().compareTo(amount) < 0) { + throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "Insufficient wallet balance"); + } + + wallet.setBalance(wallet.getBalance().subtract(amount)); + Wallet savedWallet = walletRepository.save(wallet); + + WalletTransaction transaction = new WalletTransaction(); + transaction.setWallet(savedWallet); + transaction.setType(TransactionType.DEBIT); + transaction.setAmount(amount); + transaction.setDescription(description); + transaction.setReference(reference); + transaction.setBalanceAfter(savedWallet.getBalance()); + + return transactionRepository.save(transaction); + } + + @Transactional + public WalletTransaction credit(User user, BigDecimal amount, String description, String reference) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Credit amount must be positive"); + } + + Wallet wallet = getOrCreateWallet(user); + + wallet.setBalance(wallet.getBalance().add(amount)); + Wallet savedWallet = walletRepository.save(wallet); + + WalletTransaction transaction = new WalletTransaction(); + transaction.setWallet(savedWallet); + transaction.setType(TransactionType.CREDIT); + transaction.setAmount(amount); + transaction.setDescription(description); + transaction.setReference(reference); + transaction.setBalanceAfter(savedWallet.getBalance()); + + return transactionRepository.save(transaction); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 0577cba..a269a6b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -13,11 +13,17 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; import com.flexcodelabs.flextuma.core.helpers.TemplateUtils; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; + +import org.springframework.beans.factory.annotation.Value; +import java.math.BigDecimal; import lombok.RequiredArgsConstructor; @@ -29,6 +35,10 @@ public class NotificationService { private final SmsLogRepository logRepository; private final UserRepository userRepository; private final SmsConnectorRepository connectorRepository; + private final WalletService walletService; + + @Value("${flextuma.sms.price-per-segment:1.0}") + private BigDecimal pricePerSegment; @Transactional public SmsLog queueTemplatedSms(Map placeholders, String username) { @@ -64,6 +74,11 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); + SmsSegmentResult segmentResult = SmsSegmentCalculator.calculate(finalMessage); + BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + + walletService.debit(currentUser, cost, "SMS send to " + phoneNumber, null); + SmsLog log = new SmsLog(); log.setRecipient(phoneNumber); log.setContent(finalMessage); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1c14310..af776f2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -23,4 +23,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_L logging.level.org.springframework.orm.jpa=${JPA_LEVEL:DEBUG} logging.level.org.springframework.transaction=${TRANSACTION_LEVEL:DEBUG} logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper=${SQL_EXCEPTION_HELPER_LEVEL:DEBUG} -spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} \ No newline at end of file +spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} + +# SMS Pricing +flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0} \ No newline at end of file diff --git a/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java new file mode 100644 index 0000000..27ad235 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java @@ -0,0 +1,36 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import static org.junit.jupiter.api.Assertions.*; + +class WalletTest { + + @Test + void testDefaultConstructorAndInitialValues() { + Wallet wallet = new Wallet(); + assertEquals(BigDecimal.ZERO, wallet.getBalance()); + assertEquals("TZS", wallet.getCurrency()); + assertNull(wallet.getVersion()); + } + + @Test + void testGettersAndSetters() { + Wallet wallet = new Wallet(); + wallet.setBalance(new BigDecimal("150.75")); + wallet.setCurrency("USD"); + wallet.setVersion(2L); + + assertEquals(new BigDecimal("150.75"), wallet.getBalance()); + assertEquals("USD", wallet.getCurrency()); + assertEquals(2L, wallet.getVersion()); + } + + @Test + void testAllArgsConstructor() { + Wallet wallet = new Wallet(new BigDecimal("200.00"), "KES", 1L); + assertEquals(new BigDecimal("200.00"), wallet.getBalance()); + assertEquals("KES", wallet.getCurrency()); + assertEquals(1L, wallet.getVersion()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java new file mode 100644 index 0000000..f379b7a --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java @@ -0,0 +1,48 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import com.flexcodelabs.flextuma.core.enums.TransactionType; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import static org.junit.jupiter.api.Assertions.*; + +class WalletTransactionTest { + + @Test + void testGettersAndSetters() { + WalletTransaction transaction = new WalletTransaction(); + Wallet wallet = new Wallet(); + + transaction.setWallet(wallet); + transaction.setType(TransactionType.CREDIT); + transaction.setAmount(new BigDecimal("50.00")); + transaction.setDescription("Test Credit"); + transaction.setReference("REF-123"); + transaction.setBalanceAfter(new BigDecimal("150.00")); + + assertEquals(wallet, transaction.getWallet()); + assertEquals(TransactionType.CREDIT, transaction.getType()); + assertEquals(new BigDecimal("50.00"), transaction.getAmount()); + assertEquals("Test Credit", transaction.getDescription()); + assertEquals("REF-123", transaction.getReference()); + assertEquals(new BigDecimal("150.00"), transaction.getBalanceAfter()); + } + + @Test + void testAllArgsConstructor() { + Wallet wallet = new Wallet(); + WalletTransaction transaction = new WalletTransaction( + wallet, + TransactionType.DEBIT, + new BigDecimal("25.00"), + "Test Debit", + "REF-456", + new BigDecimal("75.00")); + + assertEquals(wallet, transaction.getWallet()); + assertEquals(TransactionType.DEBIT, transaction.getType()); + assertEquals(new BigDecimal("25.00"), transaction.getAmount()); + assertEquals("Test Debit", transaction.getDescription()); + assertEquals("REF-456", transaction.getReference()); + assertEquals(new BigDecimal("75.00"), transaction.getBalanceAfter()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java b/src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java new file mode 100644 index 0000000..dbb7f0f --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java @@ -0,0 +1,22 @@ +package com.flexcodelabs.flextuma.core.enums; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TransactionTypeTest { + + @Test + void testEnumValuesAndValueOf() { + // Test values structure + TransactionType[] types = TransactionType.values(); + assertEquals(2, types.length); + + // Test valueOf + assertEquals(TransactionType.CREDIT, TransactionType.valueOf("CREDIT")); + assertEquals(TransactionType.DEBIT, TransactionType.valueOf("DEBIT")); + + // Assert specific enum ordinals/names just to guarantee they exist safely + assertEquals("CREDIT", TransactionType.CREDIT.name()); + assertEquals("DEBIT", TransactionType.DEBIT.name()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java new file mode 100644 index 0000000..6fe2181 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java @@ -0,0 +1,132 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.ser.FilterProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomPropertyFilterTest { + + private ObjectMapper objectMapper; + private MockHttpServletRequest request; + + @JsonFilter("customFilter") + static class TestEntity { + public UUID id = UUID.randomUUID(); + public String name = "Test Name"; + public String description = "Test Description"; + public int page = 1; // technical field + public NestedEntity nested = new NestedEntity(); + public TestEntity circular; // for circular reference test + } + + @JsonFilter("customFilter") + static class NestedEntity { + public String details = "Nested Details"; + public String hidden = "Should Be Hidden"; + } + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.disable(SerializationFeature.FAIL_ON_SELF_REFERENCES); + FilterProvider filters = new SimpleFilterProvider() + .addFilter("customFilter", new CustomPropertyFilter()); + objectMapper.setFilterProvider(filters); + + request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void shouldSerializeAllFieldsWhenNoFieldsParam() throws JsonProcessingException { + TestEntity entity = new TestEntity(); + String json = objectMapper.writeValueAsString(entity); + + assertTrue(json.contains("id")); + assertTrue(json.contains("name")); + assertTrue(json.contains("description")); + assertTrue(json.contains("page")); + assertTrue(json.contains("nested")); + assertTrue(json.contains("details")); + assertTrue(json.contains("hidden")); + } + + @Test + void shouldSerializeAllFieldsWhenFieldsParamIsAsterisk() throws JsonProcessingException { + request.setParameter("fields", "*"); + TestEntity entity = new TestEntity(); + String json = objectMapper.writeValueAsString(entity); + + assertTrue(json.contains("name")); + assertTrue(json.contains("description")); + assertTrue(json.contains("nested")); + } + + @Test + void shouldFilterFieldsBasedOnParam() throws JsonProcessingException { + request.setParameter("fields", "name,nested[details]"); + TestEntity entity = new TestEntity(); + String json = objectMapper.writeValueAsString(entity); + + // Technical fields are always included + assertTrue(json.contains("id")); + assertTrue(json.contains("page")); + + // Requested fields are included + assertTrue(json.contains("name")); + assertTrue(json.contains("nested")); + assertTrue(json.contains("details")); + + // Non-requested fields are excluded + assertFalse(json.contains("description")); + assertFalse(json.contains("hidden")); + } + + @Test + void shouldHandleCircularReferencesGracefully() throws JsonProcessingException { + request.setParameter("fields", "*"); + TestEntity entity = new TestEntity(); + entity.circular = entity; // Create circular reference + + String json = objectMapper.writeValueAsString(entity); + + // Should not throw StackOverflowError and should serialize successfully + assertTrue(json.contains("name")); + } + + @Test + void shouldHandleDeepNestingGracefully() throws JsonProcessingException { + // Build a deeply nested structure (depth > 10) + TestEntity root = new TestEntity(); + TestEntity current = root; + for (int i = 0; i < 15; i++) { + current.circular = new TestEntity(); + current = current.circular; + } + + request.setParameter("fields", "*"); + String json = objectMapper.writeValueAsString(root); + + // Should not throw StackOverflowError and should only serialize up to depth + // limit + assertNotNull(json); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java new file mode 100644 index 0000000..4203cf2 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java @@ -0,0 +1,226 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.criteria.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GenericSpecificationTest { + + @Mock + private Root root; + + @Mock + private CriteriaQuery query; + + @Mock + private CriteriaBuilder cb; + + @Mock + private Path path; + + @Mock + private Path stringPath; + + @Mock + private Predicate predicate; + + @Mock + private CriteriaBuilder.In inClause; + + static class TestEntity extends BaseEntity { + // dummy entity + } + + enum TestEnum { + ONE, TWO + } + + @BeforeEach + void setUp() { + // Set up the path mock for standard operations. Note that "javaType" mocking is + // important! + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void mockPathForType(Class type) { + when(root.get(anyString())).thenReturn((Path) path); + lenient().when(path.getJavaType()).thenReturn((Class) type); + } + + @Test + void testEqOperatorString() { + mockPathForType(String.class); + when(cb.equal(path, "value")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:EQ:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, "value"); + } + + @Test + void testEqOperatorUUID() { + mockPathForType(UUID.class); + UUID id = UUID.randomUUID(); + when(cb.equal(path, id)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("id:EQ:" + id); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, id); + } + + @Test + void testEqOperatorBoolean() { + mockPathForType(Boolean.class); + when(cb.equal(path, true)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("active:EQ:true"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, true); + } + + @Test + void testEqOperatorEnum() { + mockPathForType(TestEnum.class); + when(cb.equal(path, TestEnum.ONE)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("status:EQ:ONE"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, TestEnum.ONE); + } + + @Test + void testNeOperator() { + mockPathForType(String.class); + when(cb.notEqual(path, "value")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:NE:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).notEqual(path, "value"); + } + + @Test + void testLikeOperator() { + mockPathForType(String.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lower(any())).thenReturn(stringPath); + when(cb.like(any(), anyString())).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:LIKE:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).like(any(), eq("%value%")); + } + + @Test + void testILikeOperator() { + mockPathForType(String.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lower(any())).thenReturn(stringPath); + when(cb.like(any(), anyString())).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:ILIKE:VaLuE"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).like(any(), eq("%value%")); + } + + @Test + void testInOperator() { + mockPathForType(String.class); + when(path.in(anyList())).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("status:IN:A,B,C"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(path).in(anyList()); + } + + @Test + void testGtOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.greaterThan(any(), eq("10"))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:GT:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).greaterThan(any(), eq("10")); + } + + @Test + void testLtOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lessThan(any(), eq("10"))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:LT:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).lessThan(any(), eq("10")); + } + + @Test + void testNullOperator() { + mockPathForType(String.class); + lenient().when(cb.equal(any(Expression.class), eq((Object) null))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:EQ:null"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, (Object) null); + } + + @Test + void testInvalidEnumShouldReturnNullForValue() { + mockPathForType(TestEnum.class); + lenient().when(cb.equal(any(Expression.class), eq((Object) null))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("status:EQ:INVALID_ENUM"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + // an invalid enum returns null so it calls cb.equal(path, null) + verify(cb).equal(path, (Object) null); + } + + @Test + void testMissingValue() { + mockPathForType(String.class); + when(cb.equal(path, "")).thenReturn(predicate); + + // String splitting "field:EQ" shouldn't throw but defaults value to "" + GenericSpecification spec = new GenericSpecification<>("name:EQ"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, ""); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java new file mode 100644 index 0000000..219176b --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java @@ -0,0 +1,49 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SmsSegmentCalculatorTest { + + @Test + void testEmptyMessage() { + SmsSegmentResult result = SmsSegmentCalculator.calculate(""); + assertEquals(0, result.segments()); + assertEquals(0, result.length()); + assertTrue(result.isGsm7()); + } + + @Test + void testNullMessage() { + SmsSegmentResult result = SmsSegmentCalculator.calculate(null); + assertEquals(0, result.segments()); + assertEquals(0, result.length()); + assertTrue(result.isGsm7()); + } + + @ParameterizedTest + @CsvSource({ + // GSM-7 Test Cases + "Hello World, 1, true, 11", + "This is a standard message that fits exactly in one single segment without any special formatting thus taking one segment 0123456789 0123456789 012345678, 1, true, 153", + "This is a standard message that exceeds one single segment so it will be split into two segments as it has more than strictly one hundred and sixty character length in gsm7., 2, true, 173", + "Hello {}, 1, true, 10", + "Habari ya asubuhi, 1, true, 17", + "Swahili text without weird chars, 1, true, 32", + + // Unicode Test Cases + "Hello 🌍, 1, false, 8", + "This message contains special characters \u00E1 which makes it non gsm7 actually á is extended in gsm wait no á is not in default gsm7 alphabet, 3, false, 138", + "Mambo vipi 🎉 Tuna ofa mpya kwako! Njoo ujipatie punguzo la asilimia kumi kwa kila bidhaa utakayonunua., 2, false, 103" + }) + void testSegmentCalculations(String message, int expectedSegments, boolean expectedGsm7, int expectedLength) { + SmsSegmentResult result = SmsSegmentCalculator.calculate(message); + assertEquals(expectedSegments, result.segments()); + assertEquals(expectedGsm7, result.isGsm7()); + assertEquals(expectedLength, result.length()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java new file mode 100644 index 0000000..a9b9f17 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -0,0 +1,173 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private SmsTemplateRepository templateRepository; + + @Mock + private SmsLogRepository logRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private SmsConnectorRepository connectorRepository; + + @Mock + private WalletService walletService; + + @InjectMocks + private NotificationService notificationService; + + @Captor + private ArgumentCaptor smsLogCaptor; + + private User testUser; + private Map validPlaceholders; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setUsername("testuser"); + + validPlaceholders = new HashMap<>(); + validPlaceholders.put("provider", "Twilio"); + validPlaceholders.put("templateCode", "WELCOME"); + validPlaceholders.put("phoneNumber", "+1234567890"); + validPlaceholders.put("name", "John Doe"); // Custom placeholder + + ReflectionTestUtils.setField(notificationService, "pricePerSegment", BigDecimal.valueOf(20.0)); + } + + @Test + void queueTemplatedSms_shouldThrowWhenUsernameIsNull() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, null)); + + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + assertTrue(ex.getReason().contains("User not authenticated")); + } + + @Test + void queueTemplatedSms_shouldThrowWhenUserNotFound() { + when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "unknown")); + + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + assertTrue(ex.getReason().contains("User not found")); + } + + @ParameterizedTest + @CsvSource({ + "provider, SMS provider is missing", + "templateCode, Template is missing", + "phoneNumber, Phone number is missing" + }) + void queueTemplatedSms_shouldThrowWhenRequiredPlaceholderMissing(String missingKey, String expectedMessage) { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + validPlaceholders.remove(missingKey); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains(expectedMessage)); + } + + @Test + void queueTemplatedSms_shouldThrowWhenTemplateNotFound() { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + assertTrue(ex.getReason().contains("Template not found or you don't have access to it")); + } + + @Test + void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + .thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("No active SMS connector found")); + } + + @Test + void queueTemplatedSms_shouldQueueSmsSuccessfully() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); + + SmsConnector connector = new SmsConnector(); + connector.setProvider("Twilio"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + .thenReturn(Optional.of(connector)); + + SmsLog expectedSavedLog = new SmsLog(); + expectedSavedLog.setStatus(SmsLogStatus.PENDING); + when(logRepository.save(any(SmsLog.class))).thenReturn(expectedSavedLog); + + SmsLog result = notificationService.queueTemplatedSms(validPlaceholders, "testuser"); + + verify(logRepository).save(smsLogCaptor.capture()); + SmsLog capturedLog = smsLogCaptor.getValue(); + + assertEquals("+1234567890", capturedLog.getRecipient()); + assertEquals("Hello John Doe", capturedLog.getContent()); + assertEquals(template, capturedLog.getTemplate()); + assertEquals(connector, capturedLog.getConnector()); + assertEquals(SmsLogStatus.PENDING, capturedLog.getStatus()); + + assertNotNull(result); + } +} From 4f8fb3aa898cbc61c6da6721436fe446a7565c67 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 20:22:13 +0300 Subject: [PATCH 07/16] Add sms triggers from external systems --- README.md | 9 ++- build.gradle | 1 + .../core/services/RateLimiterService.java | 43 +++++++++++ .../services/DataHydratorService.java | 53 ++++++++++++- .../services/NotificationService.java | 7 ++ .../sms/controllers/PreviewRequest.java | 6 ++ .../sms/controllers/PreviewResponse.java | 4 + .../controllers/SmsTemplateController.java | 16 ++++ .../webhook/controllers/DispatchRequest.java | 11 +++ .../controllers/SmsWebhookController.java | 49 +++++++++++- .../core/services/RateLimiterServiceTest.java | 47 +++++++++++ .../services/DataHydratorServiceTest.java | 6 +- .../services/NotificationServiceTest.java | 4 + .../SmsTemplateControllerTest.java | 24 +++++- .../controllers/SmsWebhookControllerTest.java | 77 ++++++++++++++++++- 15 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java diff --git a/README.md b/README.md index e38bcf6..6a88640 100644 --- a/README.md +++ b/README.md @@ -373,12 +373,15 @@ See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, - [x] Template placeholder engine (`{{variable}}` syntax with missing-variable detection) - [x] SMS segment calculator (GSM-7 vs Unicode encoding) - [x] Wallet & ledger system with pre-flight balance checks +- [x] Async SMS dispatch worker (`@Scheduled` + `SmsLog` status lifecycle) +- [x] Rate Limiter (Bucket4j per-tenant quotas) +- [x] Webhook DLR receiver & Recipient Resolver Trigger API (`/api/webhooks...`) +- [x] Character Count & Preview API (`/api/smsTemplates/preview`) **Immediate next steps:** -- [ ] Async SMS dispatch worker (`@Scheduled` + `SmsLog` status lifecycle) +- [ ] Admin Monitoring API enhancements (query by status, retry endpoint) +- [ ] Scheduling Engine (future-dated campaigns) - [ ] Personal Access Token (PAT) entity and filter for API / gateway access -- [ ] DLR webhook receiver for delivery report tracking -- [ ] Bulk campaign entity + contact list dispatch --- diff --git a/build.gradle b/build.gradle index 4d3ac70..f313cfc 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'com.bucket4j:bucket4j-core:8.10.1' } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java new file mode 100644 index 0000000..da1b576 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java @@ -0,0 +1,43 @@ +package com.flexcodelabs.flextuma.core.services; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class RateLimiterService { + + private final Map buckets = new ConcurrentHashMap<>(); + + private Bucket createNewBucket(UUID tenantId) { + Bandwidth limit = Bandwidth.builder() + .capacity(10) + .refillGreedy(10, Duration.ofSeconds(1)) + .build(); + return Bucket.builder().addLimit(limit).build(); + } + + public void checkRateLimit(UUID tenantId) { + if (tenantId == null) { + return; + } + + Bucket bucket = buckets.computeIfAbsent(tenantId, this::createNewBucket); + + if (!bucket.tryConsume(1)) { + log.warn("Rate limit exceeded for tenant/user {}", tenantId); + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Rate limit exceeded. Please try again later."); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java index c0ead5a..f550aee 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java @@ -12,6 +12,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.List; @@ -23,10 +27,13 @@ public class DataHydratorService { private final ConnectorConfigRepository repository; private final RestClient restClient; + private final ObjectMapper objectMapper; - public DataHydratorService(ConnectorConfigRepository repository, RestClient.Builder restClientBuilder) { + public DataHydratorService(ConnectorConfigRepository repository, RestClient.Builder restClientBuilder, + ObjectMapper objectMapper) { this.repository = repository; this.restClient = restClientBuilder.build(); + this.objectMapper = objectMapper; } public Map getMemberData(String tenantId, String memberId) { @@ -49,6 +56,50 @@ public Map getMemberData(String tenantId, String memberId) { } } + public List> getRecipients(String tenantId, Map filterQuery) { + ConnectorConfig config = repository.findByTenantId(tenantId) + .orElseThrow(() -> new RuntimeException("Connector not configured for tenant: " + tenantId)); + + if (config.getSearch() == null || config.getSearch().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Connector does not have a search endpoint configured"); + } + + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(config.getUrl() + config.getSearch()); + if (filterQuery != null) { + filterQuery.forEach(uriBuilder::queryParam); + } + + try { + String rawJsonResponse = restClient.get() + .uri(uriBuilder.build().toUri()) + .headers(h -> applyAuthentication(h, config)) + .retrieve() + .body(String.class); + + JsonNode rootNode = objectMapper.readTree(rawJsonResponse); + + if (!rootNode.isArray()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Expected JSON array from external search endpoint"); + } + + java.util.List> recipients = new java.util.ArrayList<>(); + for (JsonNode node : rootNode) { + // Apply mappings to each array element individually + Map mappedItem = applyMappings(node.toString(), config.getMappings()); + recipients.add(mappedItem); + } + return recipients; + + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + log.error("Failed to fetch recipients for tenant {}: {}", tenantId, e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "External API call failed"); + } + } + private Map applyMappings(String json, List mappings) { Map hydratedData = new HashMap<>(); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index a269a6b..78678cb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -21,6 +21,8 @@ import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import com.flexcodelabs.flextuma.core.services.RateLimiterService; +import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import java.math.BigDecimal; @@ -36,6 +38,7 @@ public class NotificationService { private final UserRepository userRepository; private final SmsConnectorRepository connectorRepository; private final WalletService walletService; + private final RateLimiterService rateLimiterService; @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; @@ -51,6 +54,10 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); + UUID tenantId = currentUser.getOrganisation() != null ? currentUser.getOrganisation().getId() + : currentUser.getId(); + rateLimiterService.checkRateLimit(tenantId); + String providerValue = Optional.ofNullable(placeholders.get("provider")) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "SMS provider is missing")); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java new file mode 100644 index 0000000..57e731e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java @@ -0,0 +1,6 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +import java.util.Map; + +public record PreviewRequest(String template, Map variables) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java new file mode 100644 index 0000000..add104e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java @@ -0,0 +1,4 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +public record PreviewResponse(String renderedContent, int segmentCount, String encoding) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java index 28b4243..2338145 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java @@ -7,6 +7,14 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.core.helpers.TemplateUtils; + @RestController @RequestMapping("/api/" + SmsTemplate.PLURAL) public class SmsTemplateController extends BaseController { @@ -14,4 +22,12 @@ public class SmsTemplateController extends BaseController preview(@RequestBody PreviewRequest request) { + String rendered = TemplateUtils.fillTemplate(request.template(), request.variables()); + SmsSegmentResult segments = SmsSegmentCalculator.calculate(rendered); + String encoding = segments.isGsm7() ? "GSM-7" : "UCS-2"; + return ResponseEntity.ok(new PreviewResponse(rendered, segments.segments(), encoding)); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java new file mode 100644 index 0000000..c4d7661 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java @@ -0,0 +1,11 @@ +package com.flexcodelabs.flextuma.modules.webhook.controllers; + +import lombok.Data; +import java.util.Map; + +@Data +public class DispatchRequest { + private String templateCode; + private String provider; + private Map filterQuery; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java index 0b01e70..8bf19e3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java @@ -10,8 +10,14 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import com.flexcodelabs.flextuma.core.entities.connector.ConnectorConfig; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.modules.connector.services.ConnectorConfigService; +import com.flexcodelabs.flextuma.modules.connector.services.DataHydratorService; +import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.webhooks.DlrParser; @@ -36,15 +42,23 @@ */ @Slf4j @RestController -@RequestMapping("/api") +@RequestMapping("/api/webhooks") public class SmsWebhookController { private final SmsLogRepository logRepository; private final List dlrParsers; + private final ConnectorConfigService configService; + private final DataHydratorService hydratorService; + private final NotificationService notificationService; - public SmsWebhookController(SmsLogRepository logRepository, List dlrParsers) { + public SmsWebhookController(SmsLogRepository logRepository, List dlrParsers, + ConnectorConfigService configService, DataHydratorService hydratorService, + NotificationService notificationService) { this.logRepository = logRepository; this.dlrParsers = dlrParsers; + this.configService = configService; + this.hydratorService = hydratorService; + this.notificationService = notificationService; } @PostMapping("/{provider}") @@ -97,4 +111,35 @@ public ResponseEntity deliveryReport( return ResponseEntity.ok().build(); } + + @PostMapping("/{id}/sms") + public ResponseEntity> triggerDispatch( + @PathVariable java.util.UUID id, + @RequestBody DispatchRequest request) { + + ConnectorConfig config = configService.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Connector config not found")); + + List> recipients = hydratorService.getRecipients(config.getTenantId(), + request.getFilterQuery()); + + String username = config.getCreatedBy() != null ? config.getCreatedBy().getUsername() : "system"; + + int queuedCount = 0; + for (Map recipient : recipients) { + recipient.put("templateCode", request.getTemplateCode()); + recipient.put("provider", request.getProvider()); + try { + notificationService.queueTemplatedSms(recipient, username); + queuedCount++; + } catch (Exception e) { + log.error("Failed to queue templated SMS for recipient via webhook trigger", e); + } + } + + return ResponseEntity.ok(Map.of( + "message", "Successfully queued messages", + "queued", queuedCount, + "totalFetched", recipients.size())); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java new file mode 100644 index 0000000..cd9ef2b --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java @@ -0,0 +1,47 @@ +package com.flexcodelabs.flextuma.core.services; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimiterServiceTest { + + @Test + void testCheckRateLimitAllowsRequestsWithinLimit() { + RateLimiterService rateLimiterService = new RateLimiterService(); + UUID tenantId = UUID.randomUUID(); + + // 10 requests should be allowed + for (int i = 0; i < 10; i++) { + assertDoesNotThrow(() -> rateLimiterService.checkRateLimit(tenantId)); + } + } + + @Test + void testCheckRateLimitThrowsWhenExceeded() { + RateLimiterService rateLimiterService = new RateLimiterService(); + UUID tenantId = UUID.randomUUID(); + + // Consume all 10 tokens + for (int i = 0; i < 10; i++) { + rateLimiterService.checkRateLimit(tenantId); + } + + // 11th request should throw Exception + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> rateLimiterService.checkRateLimit(tenantId)); + + assertEquals(HttpStatus.TOO_MANY_REQUESTS, exception.getStatusCode()); + assertTrue(exception.getReason().contains("Rate limit exceeded")); + } + + @Test + void testCheckRateLimitIgnoresNullTenantId() { + RateLimiterService rateLimiterService = new RateLimiterService(); + assertDoesNotThrow(() -> rateLimiterService.checkRateLimit(null)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java index dec81a6..2abad48 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.client.RestClient; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.List; @@ -42,13 +43,16 @@ class DataHydratorServiceTest { @Mock private RestClient.ResponseSpec responseSpec; + @Mock + private ObjectMapper objectMapper; + private DataHydratorService service; @BeforeEach void setUp() { // Mock the builder chain when(restClientBuilder.build()).thenReturn(restClient); - service = new DataHydratorService(repository, restClientBuilder); + service = new DataHydratorService(repository, restClientBuilder, objectMapper); } @Test diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java index a9b9f17..b93c330 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -23,6 +23,7 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import com.flexcodelabs.flextuma.core.services.RateLimiterService; import java.math.BigDecimal; import java.util.HashMap; @@ -51,6 +52,9 @@ class NotificationServiceTest { @Mock private WalletService walletService; + @Mock + private RateLimiterService rateLimiterService; + @InjectMocks private NotificationService notificationService; diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java index 3ce55e3..f39d31c 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java @@ -1,13 +1,21 @@ package com.flexcodelabs.flextuma.modules.sms.controllers; +import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.flexcodelabs.flextuma.core.controllers.BaseController; import com.flexcodelabs.flextuma.core.controllers.BaseControllerTest; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.modules.sms.services.SmsTemplateService; -public class SmsTemplateControllerTest extends BaseControllerTest { +import java.util.Map; + +class SmsTemplateControllerTest extends BaseControllerTest { @Mock private SmsTemplateService service; @@ -38,4 +46,18 @@ protected SmsTemplate createEntity() { protected String getBaseUrl() { return "/api/templates"; } + + @Test + void preview_shouldRenderContentAndCalculateSegments() throws Exception { + PreviewRequest req = new PreviewRequest("Hello {{name}}, your code is {{code}}", + Map.of("name", "Alice", "code", "1234")); + + mockMvc.perform(post(getBaseUrl() + "/preview") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.renderedContent").value("Hello Alice, your code is 1234")) + .andExpect(jsonPath("$.segmentCount").value(1)) + .andExpect(jsonPath("$.encoding").value("GSM-7")); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java index aae91c8..68a55ba 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; import java.util.HashMap; import java.util.List; @@ -18,10 +19,18 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.entities.connector.ConnectorConfig; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.modules.connector.services.ConnectorConfigService; +import com.flexcodelabs.flextuma.modules.connector.services.DataHydratorService; +import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.webhooks.DlrParser; import com.flexcodelabs.flextuma.core.webhooks.DlrResult; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class SmsWebhookControllerTest { @@ -32,8 +41,18 @@ class SmsWebhookControllerTest { @Mock private DlrParser dlrParser; + @Mock + private ConnectorConfigService configService; + + @Mock + private DataHydratorService hydratorService; + + @Mock + private NotificationService notificationService; + private SmsWebhookController buildController() { - return new SmsWebhookController(logRepository, List.of(dlrParser)); + return new SmsWebhookController(logRepository, List.of(dlrParser), configService, hydratorService, + notificationService); } private Map payload(String msgId, String status) { @@ -118,4 +137,60 @@ void deliveryReport_shouldNotSave_whenIntermediateStatus() { verify(logRepository, never()).save(any()); } + + @Test + void triggerDispatch_shouldCallHydratorAndQueueSms_systemUser() { + UUID id = UUID.randomUUID(); + ConnectorConfig config = new ConnectorConfig(); + config.setId(id); + config.setTenantId("tenant2"); + // No createdBy, so it should fallback to "system" + + when(configService.findById(id)).thenReturn(Optional.of(config)); + + List> mockRecipients = List.of( + new HashMap<>(Map.of("phoneNumber", "+254700000000")), + new HashMap<>(Map.of("phoneNumber", "+254711111111"))); + when(hydratorService.getRecipients(eq("tenant2"), any())).thenReturn(mockRecipients); + + DispatchRequest req = new DispatchRequest(); + req.setTemplateCode("ALERT"); + req.setProvider("beem"); + req.setFilterQuery(Map.of("group", "ALL")); + + ResponseEntity> res = buildController().triggerDispatch(id, req); + + assertEquals(200, res.getStatusCode().value()); + assertEquals(2, res.getBody().get("queued")); + assertEquals(2, res.getBody().get("totalFetched")); + + verify(notificationService, times(2)).queueTemplatedSms(any(), eq("system")); + } + + @Test + void triggerDispatch_shouldCallHydratorAndQueueSms_withCreatedBy() { + UUID id = UUID.randomUUID(); + ConnectorConfig config = new ConnectorConfig(); + config.setId(id); + config.setTenantId("tenant3"); + User creator = new User(); + creator.setUsername("john_doe"); + config.setCreatedBy(creator); + + when(configService.findById(id)).thenReturn(Optional.of(config)); + + List> mockRecipients = List.of( + new HashMap<>(Map.of("phoneNumber", "+254999999999"))); + when(hydratorService.getRecipients(eq("tenant3"), any())).thenReturn(mockRecipients); + + DispatchRequest req = new DispatchRequest(); + req.setTemplateCode("ALERT"); + + ResponseEntity> res = buildController().triggerDispatch(id, req); + + assertEquals(200, res.getStatusCode().value()); + assertEquals(1, res.getBody().get("queued")); + + verify(notificationService).queueTemplatedSms(any(), eq("john_doe")); + } } From e08fbf94448e68c153937934dfdbdc062f87c037 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 21:13:20 +0300 Subject: [PATCH 08/16] Add manual sms retries by admin for failed messages --- README.md | 2 +- .../flextuma/core/entities/sms/SmsLog.java | 2 +- .../core/helpers/SmsSegmentCalculator.java | 52 +++- .../core/helpers/SmsSegmentResult.java | 2 +- .../core/interceptors/AuditorAwareImpl.java | 6 +- .../services/NotificationService.java | 3 +- .../sms/controllers/PreviewResponse.java | 2 +- .../sms/controllers/SmsLogController.java | 24 ++ .../controllers/SmsTemplateController.java | 10 +- .../modules/sms/services/SmsLogService.java | 101 ++++++++ .../helpers/SmsSegmentCalculatorTest.java | 33 ++- .../services/NotificationServiceTest.java | 225 +++++++++--------- .../SmsTemplateControllerTest.java | 7 +- .../sms/services/SmsLogServiceTest.java | 114 +++++++++ 14 files changed, 444 insertions(+), 139 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java diff --git a/README.md b/README.md index 6a88640..9bc1f5f 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, - [x] Async SMS dispatch worker (`@Scheduled` + `SmsLog` status lifecycle) - [x] Rate Limiter (Bucket4j per-tenant quotas) - [x] Webhook DLR receiver & Recipient Resolver Trigger API (`/api/webhooks...`) -- [x] Character Count & Preview API (`/api/smsTemplates/preview`) +- [x] Character Count & Preview API (`/api/smsTemplates/preview` returning segment counts and `charactersRemaining` budget) **Immediate next steps:** - [ ] Admin Monitoring API enhancements (query by status, retry endpoint) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java index b5821ac..77314f9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java @@ -32,7 +32,7 @@ public class SmsLog extends Owner { public static final String PLURAL = "smsLogs"; public static final String NAME_PLURAL = "SMS Logs"; public static final String NAME_SINGULAR = "SMS Log"; - public static final String READ = "READ_SMS_TEMPLATES"; + public static final String READ = "READ_SMS_LOGS"; public static final String ADD = "ADD_SMS_LOGS"; public static final String DELETE = "DELETE_SMS_LOGS"; public static final String UPDATE = "UPDATE_SMS_LOGS"; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java index 54891cf..f52a3c1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java @@ -3,9 +3,35 @@ import java.util.HashSet; import java.util.Set; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component public class SmsSegmentCalculator { - private SmsSegmentCalculator() { + private int gsm7MaxLength = 160; + private int gsm7MultipartLength = 153; + private int ucs2MaxLength = 70; + private int ucs2MultipartLength = 67; + + @Value("${app.sms.segment.gsm7.max:160}") + public void setGsm7MaxLength(int gsm7MaxLength) { + this.gsm7MaxLength = gsm7MaxLength; + } + + @Value("${app.sms.segment.gsm7.multipart:153}") + public void setGsm7MultipartLength(int gsm7MultipartLength) { + this.gsm7MultipartLength = gsm7MultipartLength; + } + + @Value("${app.sms.segment.ucs2.max:70}") + public void setUcs2MaxLength(int ucs2MaxLength) { + this.ucs2MaxLength = ucs2MaxLength; + } + + @Value("${app.sms.segment.ucs2.multipart:67}") + public void setUcs2MultipartLength(int ucs2MultipartLength) { + this.ucs2MultipartLength = ucs2MultipartLength; } private static final Set GSM7_CHARS = new HashSet<>(); @@ -25,9 +51,9 @@ private SmsSegmentCalculator() { } } - public static SmsSegmentResult calculate(String message) { + public SmsSegmentResult calculate(String message) { if (message == null || message.isEmpty()) { - return new SmsSegmentResult(0, true, 0); + return new SmsSegmentResult(0, true, 0, gsm7MaxLength); } boolean isGsm7 = true; @@ -45,15 +71,29 @@ public static SmsSegmentResult calculate(String message) { int segments; int finalLength; + int maxCapacity; if (isGsm7) { finalLength = gsm7Length; - segments = finalLength <= 160 ? 1 : (int) Math.ceil((double) finalLength / 153); + if (finalLength <= gsm7MaxLength) { + segments = 1; + maxCapacity = gsm7MaxLength; + } else { + segments = (int) Math.ceil((double) finalLength / gsm7MultipartLength); + maxCapacity = segments * gsm7MultipartLength; + } } else { finalLength = message.length(); - segments = finalLength <= 70 ? 1 : (int) Math.ceil((double) finalLength / 67); + if (finalLength <= ucs2MaxLength) { + segments = 1; + maxCapacity = ucs2MaxLength; + } else { + segments = (int) Math.ceil((double) finalLength / ucs2MultipartLength); + maxCapacity = segments * ucs2MultipartLength; + } } - return new SmsSegmentResult(segments, isGsm7, finalLength); + int charactersRemaining = maxCapacity - finalLength; + return new SmsSegmentResult(segments, isGsm7, finalLength, charactersRemaining); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java index 1601f7b..6095b64 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java @@ -1,4 +1,4 @@ package com.flexcodelabs.flextuma.core.helpers; -public record SmsSegmentResult(int segments, boolean isGsm7, int length) { +public record SmsSegmentResult(int segments, boolean isGsm7, int length, int charactersRemaining) { } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java b/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java index d62248b..321ea56 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java @@ -12,13 +12,15 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.ObjectProvider; + import java.util.Optional; @Component("auditorProvider") @RequiredArgsConstructor public class AuditorAwareImpl implements AuditorAware { - private final UserRepository userRepository; + private final ObjectProvider userRepositoryProvider; private final EntityManager entityManager; @Override @@ -38,7 +40,7 @@ public Optional getCurrentAuditor() { FlushModeType originalFlushMode = entityManager.getFlushMode(); try { entityManager.setFlushMode(FlushModeType.COMMIT); - return userRepository.findByIdentifier(username); + return userRepositoryProvider.getObject().findByIdentifier(username); } finally { entityManager.setFlushMode(originalFlushMode); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 78678cb..81bc9f6 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -39,6 +39,7 @@ public class NotificationService { private final SmsConnectorRepository connectorRepository; private final WalletService walletService; private final RateLimiterService rateLimiterService; + private final SmsSegmentCalculator segmentCalculator; @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; @@ -81,7 +82,7 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); - SmsSegmentResult segmentResult = SmsSegmentCalculator.calculate(finalMessage); + SmsSegmentResult segmentResult = segmentCalculator.calculate(finalMessage); BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); walletService.debit(currentUser, cost, "SMS send to " + phoneNumber, null); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java index add104e..88d1640 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java @@ -1,4 +1,4 @@ package com.flexcodelabs.flextuma.modules.sms.controllers; -public record PreviewResponse(String renderedContent, int segmentCount, String encoding) { +public record PreviewResponse(String renderedContent, int segmentCount, String encoding, int charactersRemaining) { } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java new file mode 100644 index 0000000..bd03790 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java @@ -0,0 +1,24 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/smsLogs") +public class SmsLogController extends BaseController { + + public SmsLogController(SmsLogService service) { + super(service); + } + + @PostMapping("/{id}/retry") + public ResponseEntity retryFailedMessage(@PathVariable UUID id) { + SmsLog updatedLog = service.retryFailedMessage(id); + return ResponseEntity.ok(updatedLog); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java index 2338145..6c9cec1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java @@ -19,15 +19,19 @@ @RequestMapping("/api/" + SmsTemplate.PLURAL) public class SmsTemplateController extends BaseController { - public SmsTemplateController(SmsTemplateService service) { + private final SmsSegmentCalculator segmentCalculator; + + public SmsTemplateController(SmsTemplateService service, SmsSegmentCalculator segmentCalculator) { super(service); + this.segmentCalculator = segmentCalculator; } @PostMapping("/preview") public ResponseEntity preview(@RequestBody PreviewRequest request) { String rendered = TemplateUtils.fillTemplate(request.template(), request.variables()); - SmsSegmentResult segments = SmsSegmentCalculator.calculate(rendered); + SmsSegmentResult segments = segmentCalculator.calculate(rendered); String encoding = segments.isGsm7() ? "GSM-7" : "UCS-2"; - return ResponseEntity.ok(new PreviewResponse(rendered, segments.segments(), encoding)); + return ResponseEntity + .ok(new PreviewResponse(rendered, segments.segments(), encoding, segments.charactersRemaining())); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java new file mode 100644 index 0000000..f852db7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java @@ -0,0 +1,101 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@Service +public class SmsLogService extends BaseService { + + private final SmsLogRepository smsLogRepository; + + public SmsLogService(SmsLogRepository repository) { + this.smsLogRepository = repository; + } + + @Override + protected org.springframework.data.jpa.repository.JpaRepository getRepository() { + return smsLogRepository; + } + + @Override + protected String getReadPermission() { + return SmsLog.READ; + } + + @Override + protected String getAddPermission() { + return SmsLog.ADD; + } + + @Override + protected String getUpdatePermission() { + return SmsLog.UPDATE; + } + + @Override + protected String getDeletePermission() { + return SmsLog.DELETE; + } + + @Override + public String getEntityPlural() { + return SmsLog.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return SmsLog.PLURAL; + } + + @Override + protected String getEntitySingular() { + return SmsLog.NAME_SINGULAR; + } + + @Override + protected org.springframework.data.jpa.repository.JpaSpecificationExecutor getRepositoryAsExecutor() { + return smsLogRepository; + } + + @Override + protected void onPreSave(SmsLog entity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "SMS logs cannot be created manually"); + } + + @Override + protected SmsLog onPreUpdate(SmsLog newEntity, SmsLog oldEntity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "SMS logs cannot be updated manually"); + } + + @Override + protected void validateDelete(SmsLog entity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "SMS logs cannot be deleted"); + } + + @Transactional + public SmsLog retryFailedMessage(UUID id) { + checkPermission(SmsLog.UPDATE); + + SmsLog log = smsLogRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "SMS log not found")); + + if (log.getStatus() != SmsLogStatus.FAILED) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only failed messages can be retried"); + } + + log.setStatus(SmsLogStatus.PENDING); + log.setRetries(log.getRetries() + 1); + log.setError(null); + log.setProviderResponse(null); + + return smsLogRepository.save(log); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java index 219176b..030893d 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java @@ -11,39 +11,46 @@ class SmsSegmentCalculatorTest { @Test void testEmptyMessage() { - SmsSegmentResult result = SmsSegmentCalculator.calculate(""); + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + SmsSegmentResult result = calculator.calculate(""); assertEquals(0, result.segments()); assertEquals(0, result.length()); assertTrue(result.isGsm7()); + assertEquals(160, result.charactersRemaining()); } @Test void testNullMessage() { - SmsSegmentResult result = SmsSegmentCalculator.calculate(null); + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + SmsSegmentResult result = calculator.calculate(null); assertEquals(0, result.segments()); assertEquals(0, result.length()); assertTrue(result.isGsm7()); + assertEquals(160, result.charactersRemaining()); } @ParameterizedTest @CsvSource({ // GSM-7 Test Cases - "Hello World, 1, true, 11", - "This is a standard message that fits exactly in one single segment without any special formatting thus taking one segment 0123456789 0123456789 012345678, 1, true, 153", - "This is a standard message that exceeds one single segment so it will be split into two segments as it has more than strictly one hundred and sixty character length in gsm7., 2, true, 173", - "Hello {}, 1, true, 10", - "Habari ya asubuhi, 1, true, 17", - "Swahili text without weird chars, 1, true, 32", + "Hello World, 1, true, 11, 149", + "This is a standard message that fits exactly in one single segment without any special formatting thus taking one segment 0123456789 0123456789 012345678, 1, true, 153, 7", + "This is a standard message that exceeds one single segment so it will be split into two segments as it has more than strictly one hundred and sixty character length in gsm7., 2, true, 173, 133", + "Hello {}, 1, true, 10, 150", + "Habari ya asubuhi, 1, true, 17, 143", + "Swahili text without weird chars, 1, true, 32, 128", // Unicode Test Cases - "Hello 🌍, 1, false, 8", - "This message contains special characters \u00E1 which makes it non gsm7 actually á is extended in gsm wait no á is not in default gsm7 alphabet, 3, false, 138", - "Mambo vipi 🎉 Tuna ofa mpya kwako! Njoo ujipatie punguzo la asilimia kumi kwa kila bidhaa utakayonunua., 2, false, 103" + "Hello 🌍, 1, false, 8, 62", + "This message contains special characters \u00E1 which makes it non gsm7 actually á is extended in gsm wait no á is not in default gsm7 alphabet, 3, false, 138, 63", + "Mambo vipi 🎉 Tuna ofa mpya kwako! Njoo ujipatie punguzo la asilimia kumi kwa kila bidhaa utakayonunua., 2, false, 103, 31" }) - void testSegmentCalculations(String message, int expectedSegments, boolean expectedGsm7, int expectedLength) { - SmsSegmentResult result = SmsSegmentCalculator.calculate(message); + void testSegmentCalculations(String message, int expectedSegments, boolean expectedGsm7, int expectedLength, + int expectedRemaining) { + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + SmsSegmentResult result = calculator.calculate(message); assertEquals(expectedSegments, result.segments()); assertEquals(expectedGsm7, result.isGsm7()); assertEquals(expectedLength, result.length()); + assertEquals(expectedRemaining, result.charactersRemaining()); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java index b93c330..76b1b36 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; + import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.mockito.Captor; @@ -24,6 +25,8 @@ import org.springframework.test.util.ReflectionTestUtils; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import com.flexcodelabs.flextuma.core.services.RateLimiterService; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; import java.math.BigDecimal; import java.util.HashMap; @@ -37,141 +40,147 @@ @ExtendWith(MockitoExtension.class) class NotificationServiceTest { - @Mock - private SmsTemplateRepository templateRepository; + @Mock + private SmsTemplateRepository templateRepository; + + @Mock + private SmsLogRepository logRepository; + + @Mock + private UserRepository userRepository; - @Mock - private SmsLogRepository logRepository; + @Mock + private SmsConnectorRepository connectorRepository; - @Mock - private UserRepository userRepository; + @Mock + private WalletService walletService; - @Mock - private SmsConnectorRepository connectorRepository; + @Mock + private RateLimiterService rateLimiterService; - @Mock - private WalletService walletService; + @Mock + private SmsSegmentCalculator segmentCalculator; - @Mock - private RateLimiterService rateLimiterService; + @InjectMocks + private NotificationService notificationService; - @InjectMocks - private NotificationService notificationService; + @Captor + private ArgumentCaptor smsLogCaptor; - @Captor - private ArgumentCaptor smsLogCaptor; + private User testUser; + private Map validPlaceholders; - private User testUser; - private Map validPlaceholders; + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setUsername("testuser"); - @BeforeEach - void setUp() { - testUser = new User(); - testUser.setUsername("testuser"); + validPlaceholders = new HashMap<>(); + validPlaceholders.put("provider", "Twilio"); + validPlaceholders.put("templateCode", "WELCOME"); + validPlaceholders.put("phoneNumber", "+1234567890"); + validPlaceholders.put("name", "John Doe"); // Custom placeholder - validPlaceholders = new HashMap<>(); - validPlaceholders.put("provider", "Twilio"); - validPlaceholders.put("templateCode", "WELCOME"); - validPlaceholders.put("phoneNumber", "+1234567890"); - validPlaceholders.put("name", "John Doe"); // Custom placeholder + ReflectionTestUtils.setField(notificationService, "pricePerSegment", BigDecimal.valueOf(20.0)); + } - ReflectionTestUtils.setField(notificationService, "pricePerSegment", BigDecimal.valueOf(20.0)); - } + @Test + void queueTemplatedSms_shouldThrowWhenUsernameIsNull() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, null)); - @Test - void queueTemplatedSms_shouldThrowWhenUsernameIsNull() { - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> notificationService.queueTemplatedSms(validPlaceholders, null)); + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + assertTrue(ex.getReason().contains("User not authenticated")); + } - assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); - assertTrue(ex.getReason().contains("User not authenticated")); - } + @Test + void queueTemplatedSms_shouldThrowWhenUserNotFound() { + when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty()); - @Test - void queueTemplatedSms_shouldThrowWhenUserNotFound() { - when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "unknown")); - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> notificationService.queueTemplatedSms(validPlaceholders, "unknown")); + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + assertTrue(ex.getReason().contains("User not found")); + } - assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); - assertTrue(ex.getReason().contains("User not found")); - } + @ParameterizedTest + @CsvSource({ + "provider, SMS provider is missing", + "templateCode, Template is missing", + "phoneNumber, Phone number is missing" + }) + void queueTemplatedSms_shouldThrowWhenRequiredPlaceholderMissing(String missingKey, String expectedMessage) { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + validPlaceholders.remove(missingKey); - @ParameterizedTest - @CsvSource({ - "provider, SMS provider is missing", - "templateCode, Template is missing", - "phoneNumber, Phone number is missing" - }) - void queueTemplatedSms_shouldThrowWhenRequiredPlaceholderMissing(String missingKey, String expectedMessage) { - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - validPlaceholders.remove(missingKey); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains(expectedMessage)); + } - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - assertTrue(ex.getReason().contains(expectedMessage)); - } + @Test + void queueTemplatedSms_shouldThrowWhenTemplateNotFound() { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.empty()); - @Test - void queueTemplatedSms_shouldThrowWhenTemplateNotFound() { - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.empty()); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + assertTrue(ex.getReason().contains("Template not found or you don't have access to it")); + } - assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); - assertTrue(ex.getReason().contains("Template not found or you don't have access to it")); - } + @Test + void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); - @Test - void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { - SmsTemplate template = new SmsTemplate(); - template.setContent("Hello {{name}}"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + .thenReturn(Optional.empty()); - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) - .thenReturn(Optional.empty()); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("No active SMS connector found")); + } - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - assertTrue(ex.getReason().contains("No active SMS connector found")); - } + @Test + void queueTemplatedSms_shouldQueueSmsSuccessfully() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); - @Test - void queueTemplatedSms_shouldQueueSmsSuccessfully() { - SmsTemplate template = new SmsTemplate(); - template.setContent("Hello {{name}}"); + SmsConnector connector = new SmsConnector(); + connector.setProvider("Twilio"); - SmsConnector connector = new SmsConnector(); - connector.setProvider("Twilio"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + .thenReturn(Optional.of(connector)); + + SmsSegmentResult mockSegmentResult = new SmsSegmentResult(1, true, 14, 146); + when(segmentCalculator.calculate(anyString())).thenReturn(mockSegmentResult); + + SmsLog expectedSavedLog = new SmsLog(); + expectedSavedLog.setStatus(SmsLogStatus.PENDING); + when(logRepository.save(any(SmsLog.class))).thenReturn(expectedSavedLog); + + SmsLog result = notificationService.queueTemplatedSms(validPlaceholders, "testuser"); + + verify(logRepository).save(smsLogCaptor.capture()); + SmsLog capturedLog = smsLogCaptor.getValue(); + + assertEquals("+1234567890", capturedLog.getRecipient()); + assertEquals("Hello John Doe", capturedLog.getContent()); + assertEquals(template, capturedLog.getTemplate()); + assertEquals(connector, capturedLog.getConnector()); + assertEquals(SmsLogStatus.PENDING, capturedLog.getStatus()); - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) - .thenReturn(Optional.of(connector)); - - SmsLog expectedSavedLog = new SmsLog(); - expectedSavedLog.setStatus(SmsLogStatus.PENDING); - when(logRepository.save(any(SmsLog.class))).thenReturn(expectedSavedLog); - - SmsLog result = notificationService.queueTemplatedSms(validPlaceholders, "testuser"); - - verify(logRepository).save(smsLogCaptor.capture()); - SmsLog capturedLog = smsLogCaptor.getValue(); - - assertEquals("+1234567890", capturedLog.getRecipient()); - assertEquals("Hello John Doe", capturedLog.getContent()); - assertEquals(template, capturedLog.getTemplate()); - assertEquals(connector, capturedLog.getConnector()); - assertEquals(SmsLogStatus.PENDING, capturedLog.getStatus()); - - assertNotNull(result); - } + assertNotNull(result); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java index f39d31c..9b2832c 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java @@ -12,6 +12,7 @@ import com.flexcodelabs.flextuma.core.controllers.BaseControllerTest; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.modules.sms.services.SmsTemplateService; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; import java.util.Map; @@ -25,7 +26,8 @@ class SmsTemplateControllerTest extends BaseControllerTest getController() { if (controller == null) { - controller = new SmsTemplateController(service); + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + controller = new SmsTemplateController(service, calculator); } return controller; } @@ -58,6 +60,7 @@ void preview_shouldRenderContentAndCalculateSegments() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.renderedContent").value("Hello Alice, your code is 1234")) .andExpect(jsonPath("$.segmentCount").value(1)) - .andExpect(jsonPath("$.encoding").value("GSM-7")); + .andExpect(jsonPath("$.encoding").value("GSM-7")) + .andExpect(jsonPath("$.charactersRemaining").value(130)); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java new file mode 100644 index 0000000..94a82e4 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -0,0 +1,114 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SmsLogServiceTest { + + @Mock + private SmsLogRepository smsLogRepository; + + @InjectMocks + private SmsLogService smsLogService; + + private UUID logId; + private SmsLog smsLog; + + @BeforeEach + void setUp() { + logId = UUID.randomUUID(); + smsLog = new SmsLog(); + smsLog.setId(logId); + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setRetries(1); + smsLog.setError("Network timeout"); + smsLog.setProviderResponse("HTTP 504"); + } + + @Test + void retryFailedMessage_Success() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("SUPER_ADMIN")); + + when(smsLogRepository.findById(logId)).thenReturn(Optional.of(smsLog)); + when(smsLogRepository.save(any(SmsLog.class))).thenAnswer(i -> i.getArgument(0)); + + SmsLog retriedLog = smsLogService.retryFailedMessage(logId); + + assertNotNull(retriedLog); + assertEquals(SmsLogStatus.PENDING, retriedLog.getStatus()); + assertEquals(2, retriedLog.getRetries()); + assertNull(retriedLog.getError()); + assertNull(retriedLog.getProviderResponse()); + + verify(smsLogRepository).save(smsLog); + } + } + + @Test + void retryFailedMessage_LogNotFound() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("UPDATE_SMS_LOGS")); + + when(smsLogRepository.findById(logId)).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> smsLogService.retryFailedMessage(logId)); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + } + + @Test + void retryFailedMessage_NotFailedStatus() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of(SmsLog.UPDATE)); + + smsLog.setStatus(SmsLogStatus.DELIVERED); + when(smsLogRepository.findById(logId)).thenReturn(Optional.of(smsLog)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> smsLogService.retryFailedMessage(logId)); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("Only failed messages")); + } + } + + @Test + void retryFailedMessage_NoPermission() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("READ_SMS_LOGS")); + + assertThrows(AccessDeniedException.class, () -> smsLogService.retryFailedMessage(logId)); + } + } +} From 6d8318527706d42bf104ac111af5ed7ce2049cf5 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 21:23:35 +0300 Subject: [PATCH 09/16] Add checks for schedule time --- .../flextuma/core/entities/sms/SmsLog.java | 3 +++ .../flextuma/core/enums/SmsLogStatus.java | 3 ++- .../core/repositories/SmsLogRepository.java | 6 ++++++ .../services/NotificationService.java | 8 ++++++++ .../notification/services/SmsDispatchWorker.java | 5 ++++- .../services/SmsDispatchWorkerTest.java | 15 ++++++++++----- .../modules/sms/services/SmsLogServiceTest.java | 2 +- 7 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java index 77314f9..c0c4919 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java @@ -63,4 +63,7 @@ public class SmsLog extends Owner { @JoinColumn(name = "template") private SmsTemplate template; + @Column(name = "scheduled_at", nullable = true) + private java.time.LocalDateTime scheduledAt; + } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java index bbafbd0..1bc2806 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java @@ -4,5 +4,6 @@ public enum SmsLogStatus { PENDING, PROCESSING, SENT, - FAILED + FAILED, + DELIVERED } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index 7ba0cf5..2b1ffaf 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -16,5 +16,11 @@ public interface SmsLogRepository extends BaseRepository, List findTop50ByStatusOrderByCreatedAsc(SmsLogStatus status); + @org.springframework.data.jpa.repository.Query("SELECT s FROM SmsLog s WHERE s.status = :status AND (s.scheduledAt IS NULL OR s.scheduledAt <= :now) ORDER BY s.created ASC") + List findDueMessages( + @org.springframework.data.repository.query.Param("status") SmsLogStatus status, + @org.springframework.data.repository.query.Param("now") java.time.LocalDateTime now, + org.springframework.data.domain.Pageable pageable); + Optional findByProviderResponse(String providerResponse); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 81bc9f6..9d67c3d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -94,6 +94,14 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam log.setConnector(connector); log.setStatus(SmsLogStatus.PENDING); + if (placeholders.containsKey("scheduledAt")) { + try { + log.setScheduledAt(java.time.LocalDateTime.parse(placeholders.get("scheduledAt"))); + } catch (Exception e) { + // Fallback or ignore invalid date format for now + } + } + return logRepository.save(log); } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java index 51e60d8..4051071 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java @@ -28,7 +28,10 @@ public class SmsDispatchWorker { @Scheduled(fixedDelay = 5000) @Transactional public void dispatch() { - List pending = logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING); + List pending = logRepository.findDueMessages( + SmsLogStatus.PENDING, + java.time.LocalDateTime.now(), + org.springframework.data.domain.PageRequest.of(0, 50)); if (pending.isEmpty()) { return; diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java index aadcefd..fc2b9ea 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java @@ -52,7 +52,8 @@ private SmsLog pendingLog(String provider) { void dispatch_shouldMarkSent_whenSendSucceeds() { SmsLog log = pendingLog("beem"); when(smsSender.getProvider()).thenReturn("beem"); - when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) .thenReturn(List.of(log)); when(smsSender.sendSms(any(), any(), any())).thenReturn("provider-msg-id-123"); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); @@ -67,7 +68,8 @@ void dispatch_shouldMarkSent_whenSendSucceeds() { void dispatch_shouldRetry_whenSendFailsAndRetriesBelow3() { SmsLog log = pendingLog("beem"); when(smsSender.getProvider()).thenReturn("beem"); - when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) .thenReturn(List.of(log)); when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); @@ -84,7 +86,8 @@ void dispatch_shouldMarkFailed_whenMaxRetriesReached() { log.setRetries(2); when(smsSender.getProvider()).thenReturn("beem"); - when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) .thenReturn(List.of(log)); when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); @@ -101,7 +104,8 @@ void dispatch_shouldMarkFailed_whenNoConnector() { log.setStatus(SmsLogStatus.PENDING); log.setConnector(null); - when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) .thenReturn(List.of(log)); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); @@ -112,7 +116,8 @@ void dispatch_shouldMarkFailed_whenNoConnector() { @Test void dispatch_shouldDoNothing_whenNoPendingLogs() { - when(logRepository.findTop50ByStatusOrderByCreatedAsc(SmsLogStatus.PENDING)) + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) .thenReturn(Collections.emptyList()); worker.dispatch(); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java index 94a82e4..5816026 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -90,7 +90,7 @@ void retryFailedMessage_NotFailedStatus() { utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) .thenReturn(Set.of(SmsLog.UPDATE)); - smsLog.setStatus(SmsLogStatus.DELIVERED); + smsLog.setStatus(SmsLogStatus.SENT); when(smsLogRepository.findById(logId)).thenReturn(Optional.of(smsLog)); ResponseStatusException ex = assertThrows(ResponseStatusException.class, From 6a70b3a7751921d57345c25bb918c92471c9c0de Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 21:52:14 +0300 Subject: [PATCH 10/16] Add PATs module --- README.md | 9 +- docs/example-data.sql | 16 +++ docs/frontend-integration.md | 39 +++++++ flextuma-api.http | 94 ++++++++++++++++ .../entities/auth/PersonalAccessToken.java | 45 ++++++++ .../core/entities/auth/Privilege.java | 2 +- .../PersonalAccessTokenRepository.java | 13 +++ .../security/PatAuthenticationFilter.java | 78 ++++++++++++++ .../core/security/SecurityConfig.java | 7 +- .../flextuma/core/security/SecurityUtils.java | 8 ++ .../flextuma/core/senders/NextSmsSender.java | 83 +++++++++++++- .../PersonalAccessTokenController.java | 18 ++++ .../services/PersonalAccessTokenService.java | 100 +++++++++++++++++ .../security/PatAuthenticationFilterTest.java | 102 ++++++++++++++++++ 14 files changed, 607 insertions(+), 7 deletions(-) create mode 100644 docs/example-data.sql create mode 100644 docs/frontend-integration.md create mode 100644 flextuma-api.http create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java diff --git a/README.md b/README.md index 9bc1f5f..c36a3c8 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,9 @@ Content-Type: application/json See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, [`ROADMAP/architecture.md`](ROADMAP/architecture.md) for the multi-channel notification architecture, and [`ROADMAP/roadmap-audit.md`](ROADMAP/roadmap-audit.md) for the current implementation status of each item. **Recently completed:** +- [x] Admin Monitoring API enhancements (query by status, retry endpoint) +- [x] Scheduling Engine (future-dated campaigns) +- [x] Personal Access Token (PAT) entity and filter for API / gateway access - [x] Per-organisation feature flagging via `@FeatureGate` AOP annotation - [x] `TenantAwareSpecification` — automatic org-scoped data isolation - [x] `DataHydratorService` — external ERP integration with JSONPath field mapping @@ -379,9 +382,9 @@ See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, - [x] Character Count & Preview API (`/api/smsTemplates/preview` returning segment counts and `charactersRemaining` budget) **Immediate next steps:** -- [ ] Admin Monitoring API enhancements (query by status, retry endpoint) -- [ ] Scheduling Engine (future-dated campaigns) -- [ ] Personal Access Token (PAT) entity and filter for API / gateway access +- [ ] Implement real HTTP logic for `NextSmsSender` (currently a stub) +- [ ] Database Partitioning for `sms_log` table +- [ ] Multi-channel support (WhatsApp/Email) --- diff --git a/docs/example-data.sql b/docs/example-data.sql new file mode 100644 index 0000000..070b459 --- /dev/null +++ b/docs/example-data.sql @@ -0,0 +1,16 @@ +-- Example Data for Flextuma + +-- 1. Example Organisation +INSERT INTO organisation (id, name, active, code) +VALUES (gen_random_uuid(), 'Flex Code Labs', true, 'FLEX01'); + +-- 2. Example SMS Connector (Beem) +INSERT INTO smsconnector (id, name, provider, apiKey, secretKey, active) +VALUES (gen_random_uuid(), 'Main Beem Account', 'BEEM', 'your_beem_api_key', 'your_beem_secret', true); + +-- 3. Example SMS Template +INSERT INTO smstemplate (id, name, code, content, active, system) +VALUES (gen_random_uuid(), 'Welcome SMS', 'WELCOME_SMS', 'Hello {{name}}, welcome to Flextuma!', true, true); + +-- 4. Admin User (if not exists) +-- Note: Password hashing is usually handled at runtime, so use the /api/users endpoint ideally. diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md new file mode 100644 index 0000000..7105db7 --- /dev/null +++ b/docs/frontend-integration.md @@ -0,0 +1,39 @@ +# Frontend Integration Guide + +This guide describes how to integrate the Flextuma API into a frontend application (e.g., React, Vue, or Next.js). + +## 1. Authentication Strategy + +### For Internal Dashboards (Session-based) +Internal tools should use the standard login process, which sets a secure `SESSION` cookie. +- **Login**: `POST /api/login` +- Following requests will automatically include the cookie via `credentials: 'include'`. + +### For External Apps / Programmatic Access (PAT-based) +External services should use Personal Access Tokens in the header. +- **Header**: `X-API-KEY: ` +- **Security**: Never expose your PAT in client-side code that is public. Use it only in secure, server-side environments or behind a proxy. + +## 2. Common Patterns + +### Handling SMS Scheduling +Users can schedule messages by passing a `scheduledAt` field in ISO-8601 format: +```javascript +const payload = { + phoneNumber: "255...", + templateCode: "...", + scheduledAt: new Date(Date.now() + 3600000).toISOString() // 1 hour from now +}; +``` + +### Real-time Preview +When building a template editor, use the `/api/smsTemplates/preview` endpoint for live character counting and segment calculation. +- Useful for showing users how much a message will cost. +- Avoids server-side "surprises" on message length. + +## 3. Error Handling +The API uses standard HTTP status codes: +- `400 Bad Request`: Missing variables, invalid data. +- `401 Unauthorized`: Invalid PAT or session. +- `403 Forbidden`: Insufficient permissions. +- `429 Too Many Requests`: Rate limit exceeded (Bucket4j). diff --git a/flextuma-api.http b/flextuma-api.http new file mode 100644 index 0000000..56a1333 --- /dev/null +++ b/flextuma-api.http @@ -0,0 +1,94 @@ +# Flextuma API Specification + +This file contains examples of common API requests for the Flextuma backend. + +### Authentication + +#### Log In +```http +POST /api/login +Content-Type: application/json + +{ + "username": "admin", + "password": "yourpassword" +} +``` + +#### Generate Personal Access Token (PAT) +```http +POST /api/tokens/generate +Content-Type: application/json +Authorization: Session ... + +{ + "name": "Frontend Integration Token", + "expiresAt": "2026-12-31T23:59:59" +} +``` + +--- + +### SMS Notifications + +#### Send Templated SMS (Immediate) +```http +POST /api/notifications +Content-Type: application/json +X-API-KEY: your_raw_pat_token + +{ + "phoneNumber": "255700000000", + "templateCode": "WELCOME_SMS", + "customerName": "John Doe", + "otpCode": "123456" +} +``` + +#### Send Templated SMS (Scheduled) +```http +POST /api/notifications +Content-Type: application/json +X-API-KEY: your_raw_pat_token + +{ + "phoneNumber": "255700000000", + "templateCode": "REMAINDER_SMS", + "scheduledAt": "2026-03-10T10:00:00", + "customerName": "John Doe" +} +``` + +--- + +### SMS Logs & Monitoring + +#### List Logs (Filtered by Failed) +```http +GET /api/smsLogs?filter=status:EQ:FAILED&page=0&size=10 +X-API-KEY: your_raw_pat_token +``` + +#### Retry Failed Log +```http +POST /api/smsLogs/{{log_id}}/retry +X-API-KEY: your_raw_pat_token +``` + +--- + +### Templates + +#### Preview Template +```http +POST /api/smsTemplates/preview +Content-Type: application/json + +{ + "template": "Hello {{name}}, your balance is {{balance}}.", + "variables": { + "name": "Alice", + "balance": "5,000 TZS" + } +} +``` diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java new file mode 100644 index 0000000..7f08a90 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java @@ -0,0 +1,45 @@ +package com.flexcodelabs.flextuma.core.entities.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "personalaccesstoken") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PersonalAccessToken extends BaseEntity { + + public static final String PLURAL = "tokens"; + public static final String NAME_PLURAL = "Personal Access Tokens"; + public static final String NAME_SINGULAR = "Personal Access Token"; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String token; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private LocalDateTime lastUsedAt; + + private LocalDateTime expiresAt; + + @Transient + private String rawToken; + +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java index 18b099c..1b08af9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java @@ -8,7 +8,7 @@ import lombok.*; @Entity -@Table(name = "privilege", schema = "public") +@Table(name = "privilege") @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java new file mode 100644 index 0000000..df995ff --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java @@ -0,0 +1,13 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PersonalAccessTokenRepository extends BaseRepository, + org.springframework.data.jpa.repository.JpaSpecificationExecutor { + Optional findByToken(String token); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java new file mode 100644 index 0000000..3195d31 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java @@ -0,0 +1,78 @@ +package com.flexcodelabs.flextuma.core.security; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class PatAuthenticationFilter extends OncePerRequestFilter { + + private final PersonalAccessTokenRepository patRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String apiKey = request.getHeader("X-API-KEY"); + + if (apiKey != null && !apiKey.isBlank()) { + String hashedToken = hashToken(apiKey); + Optional patOpt = patRepository.findByToken(hashedToken); + + if (patOpt.isPresent()) { + PersonalAccessToken pat = patOpt.get(); + + if (pat.getExpiresAt() == null || pat.getExpiresAt().isAfter(LocalDateTime.now())) { + User user = pat.getUser(); + + Set authorities = user.getRoles().stream() + .flatMap(role -> role.getPrivileges().stream()) + .map(privilege -> new SimpleGrantedAuthority(privilege.getValue())) + .collect(Collectors.toSet()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), null, authorities); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + pat.setLastUsedAt(LocalDateTime.now()); + patRepository.save(pat); + } + } + } + + filterChain.doFilter(request, response); + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (java.security.NoSuchAlgorithmException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256 algorithm not found", e); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index e902464..01e9818 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -20,9 +20,12 @@ public class SecurityConfig { private final CustomSecurityExceptionHandler securityExceptionHandler; + private final PatAuthenticationFilter patAuthenticationFilter; - public SecurityConfig(CustomSecurityExceptionHandler securityExceptionHandler) { + public SecurityConfig(CustomSecurityExceptionHandler securityExceptionHandler, + PatAuthenticationFilter patAuthenticationFilter) { this.securityExceptionHandler = securityExceptionHandler; + this.patAuthenticationFilter = patAuthenticationFilter; } @Bean @@ -48,6 +51,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .requestMatchers("/api/login").permitAll() .anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()) + .addFilterBefore(patAuthenticationFilter, + org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint(securityExceptionHandler) .accessDeniedHandler(securityExceptionHandler)) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java index c18ec84..d85718f 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java @@ -26,4 +26,12 @@ public static Set getCurrentUserAuthorities() { .map(GrantedAuthority::getAuthority) .collect(Collectors.toSet()); } + + public static String getCurrentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + return null; + } + return auth.getName(); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java index 56e23e2..1482d44 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java @@ -1,6 +1,16 @@ package com.flexcodelabs.flextuma.core.senders; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Collections; +import java.util.Base64; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.services.SmsSender; @@ -10,6 +20,13 @@ @Slf4j @Service public class NextSmsSender implements SmsSender { + + private final RestTemplate restTemplate; + + public NextSmsSender(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + @Override public String getProvider() { return "NEXT"; @@ -17,9 +34,71 @@ public String getProvider() { @Override public String sendSms(SmsConnector config, String to, String message) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + String auth = config.getKey() + ":" + config.getSecret(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes()); + headers.set("Authorization", "Basic " + encodedAuth); + + NextSmsRequest requestBody = new NextSmsRequest(); + requestBody.setFrom(config.getSenderId()); + requestBody.setTo(to); + requestBody.setText(message); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity( + config.getUrl(), + entity, + NextSmsResponse.class); + + NextSmsResponse responseBody = response.getBody(); + + if (responseBody != null && responseBody.getMessages() != null && !responseBody.getMessages().isEmpty()) { + NextSmsResponse.MessageDetail detail = responseBody.getMessages().get(0); + if (detail.getStatus() != null && detail.getStatus().getGroupId() > 2) { + log.warn("NextSMS: Potential error in delivery: {}", detail.getStatus().getName()); + } + return detail.getMessageId(); + } + + return "SUCCESS"; + + } catch (Exception e) { + log.error("NextSMS Error: {}", e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Failed to send via NextSMS: " + e.getMessage()); + } + } + + @lombok.Data + static class NextSmsRequest { + private String from; + private String to; + private String text; + } + + @lombok.Data + static class NextSmsResponse { + private List messages; - log.info("NEXT SMS SENDER: Sending SMS to {} with message: {}", to, message); + @lombok.Data + static class MessageDetail { + private String to; + private Status detail; + private Status status; + private String messageId; + } - return "Message sent to " + to + " with content: " + message; + @lombok.Data + static class Status { + private int groupId; + private String groupName; + private int id; + private String name; + private String description; + } } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java new file mode 100644 index 0000000..2c4934b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java @@ -0,0 +1,18 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.modules.auth.services.PersonalAccessTokenService; + +@RestController +@RequestMapping("/api/tokens") +public class PersonalAccessTokenController extends BaseController { + + public PersonalAccessTokenController(PersonalAccessTokenService service) { + super(service); + } + +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java new file mode 100644 index 0000000..4762b1a --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java @@ -0,0 +1,100 @@ +package com.flexcodelabs.flextuma.modules.auth.services; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + +@Service +public class PersonalAccessTokenService extends BaseService { + + private final PersonalAccessTokenRepository repository; + private final UserRepository userRepository; + + public PersonalAccessTokenService(PersonalAccessTokenRepository repository, UserRepository userRepository) { + super(); + this.repository = repository; + this.userRepository = userRepository; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return "ALL"; + } + + @Override + protected String getAddPermission() { + return "ALL"; + } + + @Override + protected String getUpdatePermission() { + return "ALL"; + } + + @Override + protected String getDeletePermission() { + return "ALL"; + } + + @Override + public String getEntityPlural() { + return PersonalAccessToken.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return PersonalAccessToken.PLURAL; + } + + @Override + protected String getEntitySingular() { + return PersonalAccessToken.NAME_SINGULAR; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void onPreSave(PersonalAccessToken entity) { + String rawToken = "ft_" + UUID.randomUUID().toString().replace("-", ""); + entity.setRawToken(rawToken); + entity.setToken(hashToken(rawToken)); + + if (entity.getUser() == null) { + String currentUsername = com.flexcodelabs.flextuma.core.security.SecurityUtils.getCurrentUsername(); + if (currentUsername != null) { + userRepository.findByUsername(currentUsername).ifPresent(entity::setUser); + } + } + + if (entity.getActive() == null) { + entity.setActive(true); + } + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (java.security.NoSuchAlgorithmException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256 algorithm not found", e); + } + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java b/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java new file mode 100644 index 0000000..0ec3311 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java @@ -0,0 +1,102 @@ +package com.flexcodelabs.flextuma.core.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class PatAuthenticationFilterTest { + + @Mock + private PersonalAccessTokenRepository patRepository; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private PatAuthenticationFilter filter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void doFilterInternal_WithValidToken_AuthenticatesUser() throws ServletException, IOException { + String rawToken = "test-token"; + String hashedToken = hashToken(rawToken); + + User user = new User(); + user.setUsername("testuser"); + user.setRoles(Collections.emptySet()); + + PersonalAccessToken pat = new PersonalAccessToken(); + pat.setToken(hashedToken); + pat.setUser(user); + pat.setExpiresAt(LocalDateTime.now().plusDays(1)); + + when(request.getHeader("X-API-KEY")).thenReturn(rawToken); + when(patRepository.findByToken(hashedToken)).thenReturn(Optional.of(pat)); + + filter.doFilterInternal(request, response, filterChain); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals("testuser", SecurityContextHolder.getContext().getAuthentication().getPrincipal()); + verify(patRepository).save(pat); + verify(filterChain).doFilter(request, response); + } + + @Test + void doFilterInternal_WithInvalidToken_DoesNotAuthenticate() throws ServletException, IOException { + String rawToken = "invalid-token"; + String hashedToken = hashToken(rawToken); + + when(request.getHeader("X-API-KEY")).thenReturn(rawToken); + when(patRepository.findByToken(hashedToken)).thenReturn(Optional.empty()); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} From a86d65b9effde2989ddb0461c7e25ce2a7cd1a74 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 22:28:11 +0300 Subject: [PATCH 11/16] Add validation for sms senders --- README.md | 2 +- .../entities/auth/PersonalAccessToken.java | 23 ++++++++++++++ .../services/PersonalAccessTokenService.java | 19 ------------ .../sms/services/SmsConnectorService.java | 30 +++++++++++++++++++ 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c36a3c8..235ea71 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, - [x] Character Count & Preview API (`/api/smsTemplates/preview` returning segment counts and `charactersRemaining` budget) **Immediate next steps:** -- [ ] Implement real HTTP logic for `NextSmsSender` (currently a stub) +- [x] Implement real HTTP logic for `NextSmsSender` - [ ] Database Partitioning for `sms_log` table - [ ] Multi-channel support (WhatsApp/Email) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java index 7f08a90..0d92a67 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java @@ -11,6 +11,9 @@ import java.time.LocalDateTime; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + @Entity @Table(name = "personalaccesstoken") @Getter @@ -42,4 +45,24 @@ public class PersonalAccessToken extends BaseEntity { @Transient private String rawToken; + @PrePersist + public void generateToken() { + if (this.token == null) { + this.rawToken = "ft_" + java.util.UUID.randomUUID().toString().replace("-", ""); + this.token = hashToken(this.rawToken); + } + if (this.getActive() == null) { + this.setActive(true); + } + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (java.security.NoSuchAlgorithmException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256 algorithm not found", e); + } + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java index 4762b1a..4532e17 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java @@ -4,9 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; @@ -72,29 +70,12 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor( @Override protected void onPreSave(PersonalAccessToken entity) { - String rawToken = "ft_" + UUID.randomUUID().toString().replace("-", ""); - entity.setRawToken(rawToken); - entity.setToken(hashToken(rawToken)); - if (entity.getUser() == null) { String currentUsername = com.flexcodelabs.flextuma.core.security.SecurityUtils.getCurrentUsername(); if (currentUsername != null) { userRepository.findByUsername(currentUsername).ifPresent(entity::setUser); } } - - if (entity.getActive() == null) { - entity.setActive(true); - } } - private String hashToken(String token) { - try { - java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); - return java.util.HexFormat.of().formatHex(hash); - } catch (java.security.NoSuchAlgorithmException e) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256 algorithm not found", e); - } - } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java index cb12ad7..9e721ce 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java @@ -63,6 +63,36 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { return repository; } + @Override + protected void onPreSave(SmsConnector entity) { + validateProviderConfig(entity); + } + + @Override + protected SmsConnector onPreUpdate(SmsConnector newEntity, SmsConnector oldEntity) { + SmsConnector merged = super.onPreUpdate(newEntity, oldEntity); + validateProviderConfig(merged); + return merged; + } + + private void validateProviderConfig(SmsConnector entity) { + String provider = entity.getProvider(); + if ("BEEM".equalsIgnoreCase(provider) || "NEXT".equalsIgnoreCase(provider)) { + if (entity.getUrl() == null || entity.getUrl().isBlank()) { + throw new IllegalArgumentException("URL is required for " + provider); + } + if (entity.getKey() == null || entity.getKey().isBlank()) { + throw new IllegalArgumentException("API Key is required for " + provider); + } + if (entity.getSecret() == null || entity.getSecret().isBlank()) { + throw new IllegalArgumentException("Secret Key is required for " + provider); + } + if (entity.getSenderId() == null || entity.getSenderId().isBlank()) { + throw new IllegalArgumentException("Sender ID is required for " + provider); + } + } + } + @Override protected void validateDelete(SmsConnector entity) { if (Boolean.TRUE.equals(entity.getActive())) { From 2a2b86942f39e73af48da64aa295689495573e99 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 22:48:55 +0300 Subject: [PATCH 12/16] Add campaigns module --- .../core/entities/sms/SmsCampaign.java | 57 ++++++++++ .../core/enums/SmsCampaignStatus.java | 9 ++ .../repositories/SmsCampaignRepository.java | 23 ++++ .../PersonalAccessTokenController.java | 2 +- .../services/CampaignDispatchWorker.java | 107 ++++++++++++++++++ .../services/NotificationService.java | 2 +- .../controllers/SmsCampaignController.java | 16 +++ .../sms/controllers/SmsLogController.java | 2 +- .../sms/services/SmsCampaignService.java | 78 +++++++++++++ 9 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java new file mode 100644 index 0000000..0fe1868 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java @@ -0,0 +1,57 @@ +package com.flexcodelabs.flextuma.core.entities.sms; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "smscampaign") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SmsCampaign extends Owner { + + public static final String PLURAL = "campaigns"; + public static final String NAME_PLURAL = "SMS Campaigns"; + public static final String NAME_SINGULAR = "SMS Campaign"; + public static final String READ = "READ_SMS_CAMPAIGNS"; + public static final String ADD = "ADD_SMS_CAMPAIGNS"; + public static final String DELETE = "DELETE_SMS_CAMPAIGNS"; + public static final String UPDATE = "UPDATE_SMS_CAMPAIGNS"; + + @Column(nullable = false) + private String name; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_id") + private SmsTemplate template; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SmsCampaignStatus status = SmsCampaignStatus.DRAFT; + + @Column(columnDefinition = "TEXT") + private String recipients; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "connector_id", nullable = false) + private SmsConnector connector; + +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java new file mode 100644 index 0000000..5b91034 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java @@ -0,0 +1,9 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum SmsCampaignStatus { + DRAFT, + SCHEDULED, + PROCESSING, + COMPLETED, + CANCELLED +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java new file mode 100644 index 0000000..64fc361 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java @@ -0,0 +1,23 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Repository +public interface SmsCampaignRepository extends BaseRepository, + org.springframework.data.jpa.repository.JpaSpecificationExecutor { + + @Query("SELECT c FROM SmsCampaign c WHERE c.status = :status AND c.scheduledAt <= :now") + List findDueCampaigns( + @Param("status") SmsCampaignStatus status, + @Param("now") LocalDateTime now, + Pageable pageable); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java index 2c4934b..dc9388e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java @@ -8,7 +8,7 @@ import com.flexcodelabs.flextuma.modules.auth.services.PersonalAccessTokenService; @RestController -@RequestMapping("/api/tokens") +@RequestMapping("/api/" + PersonalAccessToken.PLURAL) public class PersonalAccessTokenController extends BaseController { public PersonalAccessTokenController(PersonalAccessTokenService service) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java new file mode 100644 index 0000000..a5c51d2 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java @@ -0,0 +1,107 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CampaignDispatchWorker { + + private final SmsCampaignRepository campaignRepository; + private final SmsLogRepository logRepository; + private final WalletService walletService; + private final SmsSegmentCalculator segmentCalculator; + + @Value("${flextuma.sms.price-per-segment:1.0}") + private BigDecimal pricePerSegment; + + @Scheduled(fixedDelay = 60000) + @Transactional + public void processCampaigns() { + List dueCampaigns = campaignRepository.findDueCampaigns( + SmsCampaignStatus.SCHEDULED, + LocalDateTime.now(), + PageRequest.of(0, 10)); + + if (dueCampaigns.isEmpty()) { + return; + } + + log.info("CampaignDispatchWorker: Processing {} scheduled campaign(s)", dueCampaigns.size()); + + for (SmsCampaign campaign : dueCampaigns) { + processSingleCampaign(campaign); + } + } + + private void processSingleCampaign(SmsCampaign campaign) { + try { + campaign.setStatus(SmsCampaignStatus.PROCESSING); + campaignRepository.save(campaign); + + String recipientsStr = campaign.getRecipients(); + if (recipientsStr == null || recipientsStr.isBlank()) { + campaign.setStatus(SmsCampaignStatus.COMPLETED); + campaignRepository.save(campaign); + return; + } + + String[] recipients = recipientsStr.split(","); + log.info("Processing campaign [{}] for {} recipients", campaign.getName(), recipients.length); + + SmsSegmentResult segmentResult = segmentCalculator.calculate(campaign.getContent()); + BigDecimal costPerSms = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + + for (String recipient : recipients) { + dispatchToRecipient(campaign, recipient.trim(), costPerSms); + } + + campaign.setStatus(SmsCampaignStatus.COMPLETED); + campaignRepository.save(campaign); + log.info("Campaign [{}] processing completed successfully", campaign.getName()); + + } catch (Exception e) { + log.error("Error processing campaign [{}]: {}", campaign.getName(), e.getMessage()); + } + } + + private void dispatchToRecipient(SmsCampaign campaign, String recipient, BigDecimal cost) { + if (recipient.isEmpty()) + return; + + SmsLog smsLog = new SmsLog(); + smsLog.setRecipient(recipient); + smsLog.setContent(campaign.getContent()); + smsLog.setTemplate(campaign.getTemplate()); + smsLog.setConnector(campaign.getConnector()); + smsLog.setStatus(SmsLogStatus.PENDING); + smsLog.setCreatedBy(campaign.getCreatedBy()); + + try { + walletService.debit(campaign.getCreatedBy(), cost, "Campaign SMS to " + recipient, null); + logRepository.save(smsLog); + } catch (Exception e) { + log.error("Failed to debit wallet for campaign [{}] recipient [{}]: {}", campaign.getName(), + recipient, e.getMessage()); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 9d67c3d..0726fc9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -98,7 +98,7 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam try { log.setScheduledAt(java.time.LocalDateTime.parse(placeholders.get("scheduledAt"))); } catch (Exception e) { - // Fallback or ignore invalid date format for now + // Ignore invalid date format and fallback to no-scheduling } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java new file mode 100644 index 0000000..1a25a3e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.modules.sms.services.SmsCampaignService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + SmsCampaign.PLURAL) +public class SmsCampaignController extends BaseController { + + public SmsCampaignController(SmsCampaignService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java index bd03790..cbf93fb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java @@ -9,7 +9,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/smsLogs") +@RequestMapping("/api/" + SmsLog.PLURAL) public class SmsLogController extends BaseController { public SmsLogController(SmsLogService service) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java new file mode 100644 index 0000000..6c20b05 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java @@ -0,0 +1,78 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class SmsCampaignService extends BaseService { + + private final SmsCampaignRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return SmsCampaign.READ; + } + + @Override + protected String getAddPermission() { + return SmsCampaign.ADD; + } + + @Override + protected String getUpdatePermission() { + return SmsCampaign.UPDATE; + } + + @Override + protected String getDeletePermission() { + return SmsCampaign.DELETE; + } + + @Override + public String getEntityPlural() { + return SmsCampaign.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return SmsCampaign.PLURAL; + } + + @Override + protected String getEntitySingular() { + return SmsCampaign.NAME_SINGULAR; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void onPreSave(SmsCampaign entity) { + if (entity.getStatus() == null) { + entity.setStatus(SmsCampaignStatus.SCHEDULED); + } + } + + @Override + protected void validateDelete(SmsCampaign entity) { + if (entity.getStatus() == SmsCampaignStatus.PROCESSING) { + throw new IllegalStateException("Cannot delete a campaign that is currently processing"); + } + } +} From fb05fe97499370aa4bf78b272b3945177229fa15 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 23:06:16 +0300 Subject: [PATCH 13/16] release: Add functionality to use flextuma as passthrough --- flextuma-api.http | 45 +++++++++++ .../controllers/NotificationController.java | 10 +++ .../services/NotificationService.java | 81 ++++++++++++------- .../webhook/controllers/DispatchRequest.java | 1 + .../controllers/SmsWebhookController.java | 11 ++- 5 files changed, 117 insertions(+), 31 deletions(-) diff --git a/flextuma-api.http b/flextuma-api.http index 56a1333..0ba483e 100644 --- a/flextuma-api.http +++ b/flextuma-api.http @@ -59,6 +59,19 @@ X-API-KEY: your_raw_pat_token } ``` +#### Send Passthrough SMS (Raw Content) +```http +POST /api/notifications/raw +Content-Type: application/json +X-API-KEY: your_raw_pat_token + +{ + "phoneNumber": "255700000000", + "provider": "NEXT", + "content": "This is a direct message without using a template!" +} +``` + --- ### SMS Logs & Monitoring @@ -73,6 +86,38 @@ X-API-KEY: your_raw_pat_token ```http POST /api/smsLogs/{{log_id}}/retry X-API-KEY: your_raw_pat_token + +--- + +### Webhook Triggers (Bulk Dispatch) + +#### Trigger Templated Bulk (Existing) +```http +POST /api/webhooks/{{connector_id}}/sms +Content-Type: application/json + +{ + "templateCode": "WELCOME_SMS", + "provider": "BEEM", + "filterQuery": { + "status": "active" + } +} +``` + +#### Trigger Passthrough Bulk (Raw Content) +```http +POST /api/webhooks/{{connector_id}}/sms +Content-Type: application/json + +{ + "content": "Urgent update: Our office will be closed tomorrow. Thank you!", + "provider": "NEXT", + "filterQuery": { + "type": "customer" + } +} +``` ``` --- diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index d9bd8b8..12abd02 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -25,4 +25,14 @@ public ResponseEntity send( return ResponseEntity.ok(log); } + + @PostMapping("/raw") + public ResponseEntity sendRaw( + @RequestBody Map payload, + java.security.Principal principal) { + + SmsLog log = notificationService.queueRawSms(payload, principal.getName()); + + return ResponseEntity.ok(log); + } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 0726fc9..812bbbd 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -46,57 +46,82 @@ public class NotificationService { @Transactional public SmsLog queueTemplatedSms(Map placeholders, String username) { + User currentUser = getUser(username); + checkRateLimit(currentUser); + String providerValue = getRequiredField(placeholders, "provider"); + String templateCode = getRequiredField(placeholders, "templateCode"); + String phoneNumber = getRequiredField(placeholders, "phoneNumber"); + + SmsTemplate template = templateRepository.findByCreatedByAndCode(currentUser, templateCode) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Template not found or you don't have access to it")); + + SmsConnector connector = getConnector(currentUser, providerValue); + + String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); + + return processAndSaveSms(currentUser, connector, phoneNumber, finalMessage, template, placeholders); + } + + @Transactional + public SmsLog queueRawSms(Map payload, String username) { + User currentUser = getUser(username); + checkRateLimit(currentUser); + + String providerValue = getRequiredField(payload, "provider"); + String content = getRequiredField(payload, "content"); + String phoneNumber = getRequiredField(payload, "phoneNumber"); + + SmsConnector connector = getConnector(currentUser, providerValue); + + return processAndSaveSms(currentUser, connector, phoneNumber, content, null, payload); + } + + private User getUser(String username) { if (username == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); } - - User currentUser = userRepository.findByUsername(username) + return userRepository.findByUsername(username) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); + } - UUID tenantId = currentUser.getOrganisation() != null ? currentUser.getOrganisation().getId() - : currentUser.getId(); + private void checkRateLimit(User user) { + UUID tenantId = user.getOrganisation() != null ? user.getOrganisation().getId() : user.getId(); rateLimiterService.checkRateLimit(tenantId); + } - String providerValue = Optional.ofNullable(placeholders.get("provider")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "SMS provider is missing")); - - String templateCode = Optional.ofNullable(placeholders.get("templateCode")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Template is missing")); - - String phoneNumber = Optional.ofNullable(placeholders.get("phoneNumber")) + private String getRequiredField(Map data, String key) { + return Optional.ofNullable(data.get(key)) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Phone number is missing")); - - SmsTemplate template = templateRepository.findByCreatedByAndCode(currentUser, templateCode) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Template not found or you don't have access to it")); + key + " is missing")); + } - SmsConnector connector = connectorRepository - .findByCreatedByAndProviderAndActiveTrue(currentUser, providerValue) + private SmsConnector getConnector(User user, String provider) { + return connectorRepository.findByCreatedByAndProviderAndActiveTrue(user, provider) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "No active SMS connector found for provider [" + providerValue + "]")); - - String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); + "No active SMS connector found for provider [" + provider + "]")); + } - SmsSegmentResult segmentResult = segmentCalculator.calculate(finalMessage); + private SmsLog processAndSaveSms(User user, SmsConnector connector, String phoneNumber, String content, + SmsTemplate template, Map metadata) { + SmsSegmentResult segmentResult = segmentCalculator.calculate(content); BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); - walletService.debit(currentUser, cost, "SMS send to " + phoneNumber, null); + walletService.debit(user, cost, "SMS send to " + phoneNumber, null); SmsLog log = new SmsLog(); log.setRecipient(phoneNumber); - log.setContent(finalMessage); + log.setContent(content); log.setTemplate(template); log.setConnector(connector); log.setStatus(SmsLogStatus.PENDING); + log.setCreatedBy(user); - if (placeholders.containsKey("scheduledAt")) { + if (metadata.containsKey("scheduledAt")) { try { - log.setScheduledAt(java.time.LocalDateTime.parse(placeholders.get("scheduledAt"))); + log.setScheduledAt(java.time.LocalDateTime.parse(metadata.get("scheduledAt"))); } catch (Exception e) { // Ignore invalid date format and fallback to no-scheduling } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java index c4d7661..0a4fe35 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java @@ -6,6 +6,7 @@ @Data public class DispatchRequest { private String templateCode; + private String content; private String provider; private Map filterQuery; } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java index 8bf19e3..9e4cffe 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java @@ -127,13 +127,18 @@ public ResponseEntity> triggerDispatch( int queuedCount = 0; for (Map recipient : recipients) { - recipient.put("templateCode", request.getTemplateCode()); recipient.put("provider", request.getProvider()); try { - notificationService.queueTemplatedSms(recipient, username); + if (request.getContent() != null && !request.getContent().isBlank()) { + recipient.put("content", request.getContent()); + notificationService.queueRawSms(recipient, username); + } else { + recipient.put("templateCode", request.getTemplateCode()); + notificationService.queueTemplatedSms(recipient, username); + } queuedCount++; } catch (Exception e) { - log.error("Failed to queue templated SMS for recipient via webhook trigger", e); + log.error("Failed to queue SMS for recipient via webhook trigger", e); } } From f55319da15b5043b24df95512f80cc81fa781248 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 23:30:33 +0300 Subject: [PATCH 14/16] release: Add implementation to restrict admin endpoints --- .../entities/connector/ConnectorConfig.java | 15 ++-- .../core/entities/finance/Wallet.java | 9 +++ .../entities/finance/WalletTransaction.java | 9 +++ .../core/entities/metadata/ListEntity.java | 12 +-- .../flextuma/core/entities/metadata/Tag.java | 10 ++- .../core/entities/sms/SmsCampaign.java | 14 ++-- .../core/entities/sms/SmsConnector.java | 16 ++-- .../flextuma/core/entities/sms/SmsLog.java | 14 ++-- .../core/entities/sms/SmsTemplate.java | 17 ++-- .../flextuma/core/services/BaseService.java | 10 ++- .../core/services/DataSeederService.java | 78 +++++++++---------- .../auth/services/OrganisationService.java | 5 ++ .../auth/services/PrivilegeService.java | 5 ++ .../modules/auth/services/RoleService.java | 7 +- .../modules/auth/services/UserService.java | 5 ++ .../services/TenantFeatureService.java | 5 ++ 16 files changed, 145 insertions(+), 86 deletions(-) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java index 3352b9c..704ccbb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java @@ -36,13 +36,14 @@ public class ConnectorConfig extends Owner { public static final String PLURAL = "connectorConfigs"; - public static final String NAME_PLURAL = "Connector Configs"; - public static final String NAME_SINGULAR = "Connector Config"; - - public static final String READ = "READ_CONNECTOR_CONFIGS"; - public static final String ADD = "ADD_CONNECTOR_CONFIGS"; - public static final String DELETE = "DELETE_CONNECTOR_CONFIGS"; - public static final String UPDATE = "UPDATE_CONNECTOR_CONFIGS"; + public static final String NAME_PLURAL = "ConnectorConfigs"; + public static final String NAME_SINGULAR = "ConnectorConfig"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @Column(nullable = false, unique = true, name = "tenantid") @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java index 75c6d2b..6b547ff 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java @@ -19,6 +19,15 @@ @NoArgsConstructor @AllArgsConstructor public class Wallet extends Owner { + public static final String PLURAL = "wallets"; + public static final String NAME_PLURAL = "Wallets"; + public static final String NAME_SINGULAR = "Wallet"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @Column(nullable = false, precision = 19, scale = 4) private BigDecimal balance = BigDecimal.ZERO; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java index 227819b..e208c4b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java @@ -17,6 +17,15 @@ @NoArgsConstructor @AllArgsConstructor public class WalletTransaction extends BaseEntity { + public static final String PLURAL = "walletTransactions"; + public static final String NAME_PLURAL = "WalletTransactions"; + public static final String NAME_SINGULAR = "WalletTransaction"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "wallet", referencedColumnName = "id", nullable = false, updatable = false) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java index ef8b8fd..d21dbcb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java @@ -17,13 +17,15 @@ @Setter @JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) @JsonInclude(JsonInclude.Include.NON_NULL) -public class ListEntity extends AbstractMetadataEntity { +public class ListEntity extends AbstractMetadataEntity { public static final String PLURAL = "lists"; public static final String NAME_PLURAL = "Lists"; public static final String NAME_SINGULAR = "List"; - public static final String READ = "READ_LISTS"; - public static final String ADD = "ADD_LISTS"; - public static final String DELETE = "DELETE_LISTS"; - public static final String UPDATE = "UPDATE_LISTS"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java index d7d218a..aacaae9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java @@ -22,9 +22,11 @@ public class Tag extends AbstractMetadataEntity { public static final String PLURAL = "tags"; public static final String NAME_PLURAL = "Tags"; public static final String NAME_SINGULAR = "Tag"; - public static final String READ = "READ_TAGS"; - public static final String ADD = "ADD_TAGS"; - public static final String DELETE = "DELETE_TAGS"; - public static final String UPDATE = "UPDATE_TAGS"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java index 0fe1868..3f6fc22 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java @@ -23,12 +23,14 @@ public class SmsCampaign extends Owner { public static final String PLURAL = "campaigns"; - public static final String NAME_PLURAL = "SMS Campaigns"; - public static final String NAME_SINGULAR = "SMS Campaign"; - public static final String READ = "READ_SMS_CAMPAIGNS"; - public static final String ADD = "ADD_SMS_CAMPAIGNS"; - public static final String DELETE = "DELETE_SMS_CAMPAIGNS"; - public static final String UPDATE = "UPDATE_SMS_CAMPAIGNS"; + public static final String NAME_PLURAL = "SmsCampaigns"; + public static final String NAME_SINGULAR = "SmsCampaign"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @Column(nullable = false) private String name; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java index abec510..89c697d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java @@ -17,13 +17,15 @@ @AllArgsConstructor public class SmsConnector extends Owner { - public static final String PLURAL = "smsConnectors"; - public static final String NAME_PLURAL = "SMS Connectors"; - public static final String NAME_SINGULAR = "SMS Connector"; - public static final String READ = "READ_SMS_CONNECTORS"; - public static final String ADD = "ADD_SMS_CONNECTORS"; - public static final String DELETE = "DELETE_SMS_CONNECTORS"; - public static final String UPDATE = "UPDATE_SMS_CONNECTORS"; + public static final String PLURAL = "connectors"; + public static final String NAME_PLURAL = "SmsConnectors"; + public static final String NAME_SINGULAR = "SmsConnector"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @NotBlank(message = "Provider name is required") private String provider; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java index c0c4919..06ad5e1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java @@ -30,12 +30,14 @@ public class SmsLog extends Owner { public static final String PLURAL = "smsLogs"; - public static final String NAME_PLURAL = "SMS Logs"; - public static final String NAME_SINGULAR = "SMS Log"; - public static final String READ = "READ_SMS_LOGS"; - public static final String ADD = "ADD_SMS_LOGS"; - public static final String DELETE = "DELETE_SMS_LOGS"; - public static final String UPDATE = "UPDATE_SMS_LOGS"; + public static final String NAME_PLURAL = "SmsLogs"; + public static final String NAME_SINGULAR = "SmsLog"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; private String recipient; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java index f13bb81..6497d19 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java @@ -22,13 +22,14 @@ public class SmsTemplate extends Owner { public static final String PLURAL = "templates"; - public static final String NAME_PLURAL = "SMS Templates"; - public static final String NAME_SINGULAR = "SMS Template"; + public static final String NAME_PLURAL = "SmsTemplates"; + public static final String NAME_SINGULAR = "SmsTemplate"; - public static final String READ = "READ_SMS_TEMPLATES"; - public static final String ADD = "ADD_SMS_TEMPLATES"; - public static final String DELETE = "DELETE_SMS_TEMPLATES"; - public static final String UPDATE = "UPDATE_SMS_TEMPLATES"; + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @Column(nullable = true, unique = false) private String code; @@ -36,10 +37,10 @@ public class SmsTemplate extends Owner { @Column(nullable = false, updatable = true) private String name; - @Column( nullable = true, updatable = true) + @Column(nullable = true, updatable = true) private String description; - @Column( columnDefinition = "TEXT", nullable = false) + @Column(columnDefinition = "TEXT", nullable = false) private String content; @Column(nullable = true, updatable = true) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index 5d49c0a..b75ec45 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -51,12 +51,16 @@ public void setCurrentUserResolver(CurrentUserResolver currentUserResolver) { protected abstract JpaSpecificationExecutor getRepositoryAsExecutor(); + protected boolean isAdminEntity() { + return false; + } + protected void checkPermission(String requiredPermission) { Set authorities = SecurityUtils.getCurrentUserAuthorities(); - boolean isAuthorized = authorities.contains("ALL") || - authorities.contains("SUPER_ADMIN") || - authorities.contains(requiredPermission); + boolean isAuthorized = authorities.contains("SUPER_ADMIN") || + authorities.contains(requiredPermission) || + (!isAdminEntity() && authorities.contains("ALL")); if (!isAuthorized) { throw new AccessDeniedException("You have no permission to access " + getEntityPlural()); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java index 9012c8e..459f9e3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java @@ -14,52 +14,52 @@ @RequiredArgsConstructor public class DataSeederService { - private final JdbcTemplate jdbcTemplate; - private final PasswordEncoder passwordEncoder; + private final JdbcTemplate jdbcTemplate; + private final PasswordEncoder passwordEncoder; - @Transactional - public void seedSystemData() { - UUID privId = UUID.fromString("5269df21-c8a0-4776-bd89-1015521bc19d"); - jdbcTemplate.update( - "INSERT INTO privilege (id, name, value, system, active, created, updated) " + - "VALUES (?, 'Super Admin', 'SUPER_ADMIN', true, true, NOW(), NOW()) " + - "ON CONFLICT (id) DO NOTHING", - privId); + @Transactional + public void seedSystemData() { + UUID privId = UUID.fromString("5269df21-c8a0-4776-bd89-1015521bc19d"); + jdbcTemplate.update( + "INSERT INTO privilege (id, name, value, system, active, created, updated) " + + "VALUES (?, 'Super Admin', 'SUPER_ADMIN', true, true, NOW(), NOW()) " + + "ON CONFLICT (id) DO NOTHING", + privId); - UUID roleId = UUID.fromString("6269df23-f8a0-4776-bd89-3015521bc19d"); - jdbcTemplate.update( - "INSERT INTO role (id, name, system, active, created, updated) " + - "VALUES (?, 'Super Admin', true, true, NOW(), NOW()) " + - "ON CONFLICT (id) DO NOTHING", - roleId); + UUID roleId = UUID.fromString("6269df23-f8a0-4776-bd89-3015521bc19d"); + jdbcTemplate.update( + "INSERT INTO role (id, name, system, active, created, updated) " + + "VALUES (?, 'Super Admin', true, true, NOW(), NOW()) " + + "ON CONFLICT (id) DO NOTHING", + roleId); - jdbcTemplate.update( - "INSERT INTO userprivilege (role, privilege) VALUES (?, ?) " + - "ON CONFLICT DO NOTHING", - roleId, privId); + jdbcTemplate.update( + "INSERT INTO userprivilege (role, privilege) VALUES (?, ?) " + + "ON CONFLICT DO NOTHING", + roleId, privId); - seedUser(roleId, "admin", "admin@flextuma.com", "Admin123", roleId); + seedUser(roleId, "admin", "admin@flextuma.com", "Admin123", roleId); - seedUser(privId, "SYSTEM", "system@flextuma.com", "system_secret_key", roleId); + seedUser(privId, "SYSTEM", "system@flextuma.com", "system_secret_key", roleId); - log.info("✅✅✅ System seeding via JDBC completed successfully. ✅✅✅"); - } + log.info("✅✅✅ System seeding via JDBC completed successfully. ✅✅✅"); + } - private void seedUser(UUID userId, String username, String email, String pass, UUID roleId) { - String hashedPass = passwordEncoder.encode(pass); + private void seedUser(UUID userId, String username, String email, String pass, UUID roleId) { + String hashedPass = passwordEncoder.encode(pass); - jdbcTemplate.update( - "INSERT INTO \"user\" (id, username, name, email, phonenumber, password, type, active, verified, system, created, updated) " - + - "VALUES (?, ?, ?, ?, ?, ?, 'SYSTEM', true, true, true, NOW(), NOW()) " + - "ON CONFLICT (id) DO NOTHING", - userId, username, username.toUpperCase(), email, - username.equals("SYSTEM") ? "0000000000" : "123456789", - hashedPass); + jdbcTemplate.update( + "INSERT INTO \"user\" (id, username, name, email, phonenumber, password, type, active, verified, system, created, updated) " + + + "VALUES (?, ?, ?, ?, ?, ?, 'SYSTEM', true, true, true, NOW(), NOW()) " + + "ON CONFLICT (id) DO NOTHING", + userId, username, username.toUpperCase(), email, + username.equals("SYSTEM") ? "0000000000" : "123456789", + hashedPass); - jdbcTemplate.update( - "INSERT INTO userrole (owner, role) VALUES (?, ?) " + - "ON CONFLICT DO NOTHING", - userId, roleId); - } + jdbcTemplate.update( + "INSERT INTO userrole (owner, role) VALUES (?, ?) " + + "ON CONFLICT DO NOTHING", + userId, roleId); + } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java index 468be62..a93057e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java @@ -16,6 +16,11 @@ public class OrganisationService extends BaseService { private final OrganisationRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java index 7e858d9..4d2313d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java @@ -18,6 +18,11 @@ public class PrivilegeService extends BaseService { private final PrivilegeRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java index 2601c37..4b011ca 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java @@ -18,6 +18,11 @@ public class RoleService extends BaseService { private final RoleRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; @@ -65,7 +70,7 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { @Override protected void validateDelete(Role role) { - if (role.getSystem()) { + if (Boolean.TRUE.equals(role.getSystem())) { throw new IllegalStateException("System roles cannot be deleted"); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index b6a3b6a..d43b7c4 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -20,6 +20,11 @@ public class UserService extends BaseService { private final UserRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java b/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java index d0782e0..a77c4d6 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java @@ -18,6 +18,11 @@ public class TenantFeatureService extends BaseService { private final TenantFeatureRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; From 10e3037fbb5c84b9eec41c347583f6fe09ef87b0 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 4 Mar 2026 23:44:12 +0300 Subject: [PATCH 15/16] release: Improve workflow to get version --- .github/workflows/release.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fbb2da..3c0005f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,9 @@ jobs: if: | contains(github.event.head_commit.message, 'release') && github.event_name == 'push' runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20.x] steps: - uses: actions/checkout@v4 - - name: 🔀 + - name: Create Pull Request uses: BaharaJr/create-pr@0.0.1 with: GITHUB_TOKEN: ${{secrets.TOKEN}} @@ -26,18 +23,17 @@ jobs: KEYWORD: release CHECK_MESSAGE: - if: | - github.event_name == 'pull_request' + if: github.event_name == 'pull_request' name: COMMIT CHECK runs-on: ubuntu-latest outputs: sms: ${{ steps.sms_id.outputs.sms }} steps: - - name: 🚚 + - name: Checkout PR Head uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - - name: ❇️ + - name: Get Commit Message id: sms_id run: echo "sms=$(git show -s --format=%s)" >> "$GITHUB_OUTPUT" @@ -46,11 +42,14 @@ jobs: permissions: contents: write needs: CHECK_MESSAGE + # Only run if the PR commit message contains 'release' if: ${{ contains(needs.CHECK_MESSAGE.outputs.sms, 'release') }} steps: - - name: Checkout code + - name: Checkout Source Branch uses: actions/checkout@v4 with: + # Check out the actual branch (develop), not the merge commit + ref: ${{ github.head_ref }} fetch-depth: 0 token: ${{ secrets.TOKEN }} @@ -100,8 +99,9 @@ jobs: git commit -m "Release v$NEW_VERSION [skip ci]" git tag "v$NEW_VERSION" - # 5. Push changes - git push origin main + # 5. Push changes back to the source branch (develop) + # Use HEAD:${{ github.head_ref }} to ensure it pushes to the PR source branch + git push origin HEAD:${{ github.head_ref }} git push origin "v$NEW_VERSION" - name: Build with Gradle @@ -127,4 +127,4 @@ jobs: echo "Pushing images..." docker push $IMAGE_NAME:$VERSION - docker push $IMAGE_NAME:latest + docker push $IMAGE_NAME:latest \ No newline at end of file From 3a97899a02b1b275794ef3dc2ab8e1983e3b96e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Mar 2026 20:44:39 +0000 Subject: [PATCH 16/16] Release v0.0.2 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f313cfc..3c2f1e3 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.1' +version = '0.0.2' description = 'Flextuma App' java {