diff --git a/pom.xml b/pom.xml index 49f030f..004320d 100644 --- a/pom.xml +++ b/pom.xml @@ -155,6 +155,11 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 + + com.fasterxml.jackson.datatype + jackson-datatype-joda + 2.13.1 + diff --git a/src/main/java/zw/qantra/tm/configs/JacksonConfig.java b/src/main/java/zw/qantra/tm/configs/JacksonConfig.java index bca58ba..10af2b6 100644 --- a/src/main/java/zw/qantra/tm/configs/JacksonConfig.java +++ b/src/main/java/zw/qantra/tm/configs/JacksonConfig.java @@ -16,6 +16,7 @@ public class JacksonConfig { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.registerModule(new JodaModule()); + return objectMapper; } } diff --git a/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java b/src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java similarity index 50% rename from src/main/java/zw/qantra/tm/domain/controllers/AuthController.java rename to src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java index ace5062..79acee7 100644 --- a/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java +++ b/src/main/java/zw/qantra/tm/domain/controllers/OnboardingController.java @@ -1,19 +1,26 @@ package zw.qantra.tm.domain.controllers; +import io.nflow.engine.service.WorkflowInstanceInclude; import io.nflow.engine.service.WorkflowInstanceService; +import io.nflow.engine.workflow.instance.WorkflowInstance; +import io.nflow.engine.workflow.instance.WorkflowInstanceAction; import io.nflow.engine.workflow.instance.WorkflowInstanceFactory; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.joda.time.DateTime; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import zw.qantra.tm.domain.dtos.AuthResponse; import zw.qantra.tm.domain.dtos.LoginRequest; import zw.qantra.tm.domain.dtos.RegisterRequest; +import zw.qantra.tm.domain.dtos.VerifyRequest; import zw.qantra.tm.domain.services.UserService; import zw.qantra.tm.domain.workflows.RegistrationWorkflow; import zw.qantra.tm.domain.workflows.WorkflowUtils; import zw.qantra.tm.utils.Utils; +import java.util.EnumSet; +import java.util.HashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -21,10 +28,10 @@ import java.util.concurrent.ExecutionException; @RequestMapping("/auth") @RequiredArgsConstructor @CrossOrigin(origins = "*") -public class AuthController { +public class OnboardingController { private final UserService userService; - private final WorkflowInstanceService workflowService; + private final WorkflowInstanceService workflowInstanceService; private final WorkflowInstanceFactory workflowInstanceFactory; private final WorkflowUtils workflowUtils; @@ -34,15 +41,43 @@ public class AuthController { var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() .setType(RegistrationWorkflow.TYPE) .setExternalId(request.getUsername() + "-" + System.currentTimeMillis()) - .putStateVariable("metadata", request) + .putStateVariable("body", request) .build(); - long workflowId = workflowService.insertWorkflowInstance(instance); + long workflowId = workflowInstanceService.insertWorkflowInstance(instance); CompletableFuture future = workflowUtils .waitForState(workflowId, "sendOtp", 30000, 1000); - Object result = future.get(); - return ResponseEntity.ok(Utils.toJson(result)); + Object response = future.get(); + + return ResponseEntity.ok(response); + } + + @PostMapping("/verify") + public ResponseEntity verify(@Valid @RequestBody VerifyRequest request) + throws ExecutionException, InterruptedException { + // Create an update to move the workflow to the next state + WorkflowInstance update = new WorkflowInstance.Builder() + .setId(request.getWorkflowId()) + .setState("verifyOtp") // The state you want to transition to + .setNextActivation(DateTime.now()) // Schedule immediate execution + .setStateVariables(new HashMap<>(){{ put("body", Utils.toJson(request)); }}) + .setStateText("Resumed from manual state") + .build(); + + WorkflowInstanceAction action = new WorkflowInstanceAction.Builder(update) + .setType(WorkflowInstanceAction.WorkflowActionType.externalChange) + .setExecutionEnd(DateTime.now()) + .build(); + + // Apply the update + workflowInstanceService.updateWorkflowInstance(update, action); + + CompletableFuture future = workflowUtils + .waitForState(request.getWorkflowId(), "verifyOtp", 30000, 1000); + Object response = future.get(); + + return ResponseEntity.ok(response); } @PostMapping("/login") diff --git a/src/main/java/zw/qantra/tm/domain/dtos/StateResponse.java b/src/main/java/zw/qantra/tm/domain/dtos/StateResponse.java new file mode 100644 index 0000000..9198910 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/StateResponse.java @@ -0,0 +1,18 @@ +package zw.qantra.tm.domain.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class StateResponse { + private String state; + private Object body; + private String message; + private String workflowId; + private String externalId; +} diff --git a/src/main/java/zw/qantra/tm/domain/dtos/VerifyRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/VerifyRequest.java new file mode 100644 index 0000000..0b0fae0 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/VerifyRequest.java @@ -0,0 +1,12 @@ +package zw.qantra.tm.domain.dtos; + +import lombok.Data; + +@Data +public class VerifyRequest { + private String username; + private String otpCode; + private String otpType; + private Long workflowId; + private String externalId; +} diff --git a/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java index c19474c..c39071f 100644 --- a/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java +++ b/src/main/java/zw/qantra/tm/domain/repositories/OtpRepository.java @@ -2,6 +2,7 @@ package zw.qantra.tm.domain.repositories; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.models.Otp; import java.util.Optional; @@ -10,5 +11,6 @@ import java.util.Optional; public interface OtpRepository extends JpaRepository { Optional findByUsernameAndOtpType(String username, String otpType); Optional findByUsernameAndOtpTypeAndOtp(String username, String otpType, String otp); + Optional findByUsernameAndOtpTypeAndOtpAndOtpStatus(String username, String otpType, String otp, Status status); void deleteByUsernameAndOtpType(String username, String otpType); } diff --git a/src/main/java/zw/qantra/tm/domain/services/EmailService.java b/src/main/java/zw/qantra/tm/domain/services/EmailService.java deleted file mode 100644 index bf6c387..0000000 --- a/src/main/java/zw/qantra/tm/domain/services/EmailService.java +++ /dev/null @@ -1,46 +0,0 @@ -package zw.qantra.tm.domain.services; - -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import zw.qantra.tm.domain.dtos.EmailRequest; - -import java.util.Objects; - -@Slf4j -@Service -@RequiredArgsConstructor -public class EmailService { - - private final JavaMailSender mailSender; - - /** - * Sends an email based on the provided EmailRequest - * @param emailRequest the email request containing recipient, subject, and body - * @return true if the email was sent successfully, false otherwise - */ - public boolean sendEmail(EmailRequest emailRequest) { - try { - MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, true); - - helper.setTo(emailRequest.getTo()); - if (emailRequest.getCc() != null && !emailRequest.getCc().isEmpty()) { - helper.setCc(emailRequest.getCc().toArray(new String[0])); - } - helper.setSubject(emailRequest.getSubject()); - helper.setText(emailRequest.getBody(), emailRequest.isHtml()); - - mailSender.send(message); - log.info("Email sent successfully to {}", emailRequest.getTo()); - return true; - } catch (MessagingException e) { - log.error("Failed to send email to {}: {}", emailRequest.getTo(), e.getMessage(), e); - return false; - } - } -} diff --git a/src/main/java/zw/qantra/tm/domain/services/OtpService.java b/src/main/java/zw/qantra/tm/domain/services/OtpService.java index 2551a9b..d2481a9 100644 --- a/src/main/java/zw/qantra/tm/domain/services/OtpService.java +++ b/src/main/java/zw/qantra/tm/domain/services/OtpService.java @@ -7,7 +7,6 @@ import zw.qantra.tm.domain.models.Otp; import zw.qantra.tm.domain.repositories.OtpRepository; import java.security.SecureRandom; -import java.time.LocalDateTime; import java.util.Optional; @Service @@ -21,9 +20,6 @@ public class OtpService { private final OtpRepository otpRepository; public Otp generateOtp(String username, String otpType) { - // Delete any existing OTP for this username and type - otpRepository.deleteByUsernameAndOtpType(username, otpType); - String otpCode = generateRandomOtp(); Otp otp = Otp.builder() @@ -37,7 +33,8 @@ public class OtpService { } public boolean verifyOtp(String username, String otpCode, String otpType) { - Optional otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtp(username, otpType, otpCode); + Optional otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtpAndOtpStatus( + username, otpType, otpCode, Status.ACTIVE); if (otpOpt.isEmpty()) { return false; diff --git a/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java b/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java index f6ee5e9..b1c90b0 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/RegistrationWorkflow.java @@ -3,14 +3,15 @@ package zw.qantra.tm.domain.workflows; import io.nflow.engine.workflow.definition.WorkflowStateType; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import io.nflow.engine.workflow.definition.NextAction; import io.nflow.engine.workflow.definition.StateExecution; import io.nflow.engine.workflow.definition.WorkflowDefinition; import zw.qantra.tm.domain.dtos.EmailRequest; +import zw.qantra.tm.domain.dtos.VerifyRequest; import zw.qantra.tm.domain.models.Otp; -import zw.qantra.tm.domain.services.EmailService; import zw.qantra.tm.domain.services.OtpService; import zw.qantra.tm.domain.services.UserService; import io.nflow.engine.workflow.curated.State; @@ -19,24 +20,16 @@ import zw.qantra.tm.domain.dtos.RegisterRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; +import zw.qantra.tm.rest.RestService; + +import org.springframework.http.HttpHeaders; +import java.util.LinkedHashMap; import java.util.Set; @Component public class RegistrationWorkflow extends WorkflowDefinition { Logger log = org.slf4j.LoggerFactory.getLogger(RegistrationWorkflow.class); - public static final String TYPE = "registrationWorkflow"; - - public static final State BEGIN = new State("begin", WorkflowStateType.start); - public static final State REGISTER = new State("register", WorkflowStateType.normal); - public static final State SEND_OTP = new State("sendOtp", WorkflowStateType.manual); - public static final State VERIFY_OTP = new State("verifyOtp", WorkflowStateType.normal); - public static final State RESEND_OTP = new State("resendOtp", WorkflowStateType.manual); - public static final State SET_PIN = new State("setPin", WorkflowStateType.normal); - public static final State UPDATE_ERP = new State("updateErp", WorkflowStateType.normal); - public static final State FAILED = new State("failed", WorkflowStateType.manual); - public static final State DONE = new State("done", WorkflowStateType.end); - @Autowired private UserService userService; @Autowired @@ -44,16 +37,35 @@ public class RegistrationWorkflow extends WorkflowDefinition { @Autowired private OtpService otpService; @Autowired - private EmailService emailService; + private RestService restService; + + @Value("${infobip.url}") + private String infobipUrl; + @Value("${infobip.apikey}") + private String infobipApiKey; + + public static final String TYPE = "registrationWorkflow"; + + public static final State BEGIN = new State("begin", WorkflowStateType.start); + public static final State REGISTER = new State("register"); + public static final State SEND_OTP = new State("sendOtp", WorkflowStateType.manual); + public static final State VERIFY_OTP = new State("verifyOtp"); + public static final State OTP_SUCCESS = new State("otpSuccess", WorkflowStateType.manual); + public static final State RESEND_OTP = new State("resendOtp", WorkflowStateType.manual); + public static final State SET_PIN = new State("setPin", WorkflowStateType.normal); + public static final State UPDATE_ERP = new State("updateErp", WorkflowStateType.normal); + public static final State FAILED = new State("failed", WorkflowStateType.manual); + public static final State DONE = new State("done", WorkflowStateType.end); public RegistrationWorkflow() { super(TYPE, BEGIN, FAILED); permit(BEGIN, REGISTER); permit(REGISTER, SEND_OTP); - permit(SEND_OTP, VERIFY_OTP, RESEND_OTP); - permit(RESEND_OTP, VERIFY_OTP, FAILED); - permit(VERIFY_OTP, SET_PIN, FAILED); + permit(SEND_OTP, VERIFY_OTP); + permit(VERIFY_OTP, OTP_SUCCESS, FAILED); + permit(RESEND_OTP, OTP_SUCCESS, FAILED); + permit(OTP_SUCCESS, SET_PIN, FAILED); permit(SET_PIN, UPDATE_ERP); permit(UPDATE_ERP, DONE); } @@ -65,7 +77,7 @@ public class RegistrationWorkflow extends WorkflowDefinition { // register validates most of the initial registration details public NextAction register(StateExecution execution) { - RegisterRequest payload = execution.getVariable("metadata", RegisterRequest.class); + RegisterRequest payload = execution.getVariable("body", RegisterRequest.class); if(payload == null) return NextAction.moveToState(FAILED, "No payload provided"); @@ -82,65 +94,71 @@ public class RegistrationWorkflow extends WorkflowDefinition { } // todo: add this to the appropriate action - execution.setVariable("metadata", payload); + execution.setVariable("body", payload); return NextAction.moveToState(SEND_OTP, "Initial registration success for: " + payload.getUsername()); } public void sendOtp(StateExecution execution) { try { - RegisterRequest registerRequest = execution.getVariable("metadata", RegisterRequest.class); + RegisterRequest registerRequest = execution.getVariable("body", RegisterRequest.class); String username = registerRequest.getUsername(); String email = registerRequest.getEmail(); // Generate OTP Otp otp = otpService.generateOtp(username, "REGISTRATION"); - // Create email content - String subject = "Your Verification Code"; - String body = String.format( - "" + - "

Email Verification

" + - "

Hello %s,

" + - "

Your verification code is: %s

" + - "

This code will expire in 10 minutes.

" + - "

If you didn't request this code, please ignore this email.

" + - "", - username, otp.getOtp() - ); - - // Send email - EmailRequest emailRequest = EmailRequest.builder() - .to(email) - .subject(subject) - .body(body) - .isHtml(true) - .build(); - - boolean emailSent = emailService.sendEmail(emailRequest); - - if (!emailSent) { - log.error("Failed to send OTP email to: {}", email); - } + String string = getSMSString(registerRequest.getPhone(), otp.getOtp()); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "App " + infobipApiKey); + headers.add("Content-Type", "application/json"); + headers.add("Accept", "application/json"); + // LinkedHashMap response = restService.postAs(infobipUrl, string, headers, LinkedHashMap.class); + log.info("otp generated successfully: {}", otp.getOtp()); } catch (Exception e) { log.error("Error sending OTP: {}", e.getMessage(), e); } } public NextAction verifyOtp(StateExecution execution) { - return NextAction.stopInState(SET_PIN, "OTP verified successfully for: " ); + VerifyRequest payload = execution.getVariable("body", VerifyRequest.class); + boolean otpValid = otpService.verifyOtp(payload.getUsername(), payload.getOtpCode(), payload.getOtpType()); + + if(!otpValid) { + return NextAction.moveToState(FAILED, "Invalid OTP"); + } + + return NextAction.moveToState(OTP_SUCCESS, "OTP verified successfully"); + } + + public void otpSuccess(StateExecution execution) { + log.info("OTP verified successfully"); } public NextAction setPin(StateExecution execution) { - RegisterRequest response = execution.getVariable("response", RegisterRequest.class); + RegisterRequest response = execution.getVariable("body", RegisterRequest.class); return NextAction.stopInState(UPDATE_ERP, "PIN set successfully for: " + response.getUsername()); } public NextAction updateErp(StateExecution execution) { - RegisterRequest response = execution.getVariable("response", RegisterRequest.class); + RegisterRequest response = execution.getVariable("body", RegisterRequest.class); return NextAction.stopInState(DONE, "ERP updated successfully for: " + response.getUsername()); } - + String getSMSString(String to, String otp) { + return String.format("{\n" + + " \"messages\": [\n" + + " {\n" + + " \"destinations\": [\n" + + " {\n" + + " \"to\": \"%s\"\n" + + " }\n" + + " ],\n" + + " \"from\": \"447491163443\",\n" + + " \"text\": \"Your verification code is: %s\"\n" + + " }\n" + + " ]\n" + + "}", to, otp); + } } diff --git a/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java b/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java index f3197a5..1f91cd5 100644 --- a/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java +++ b/src/main/java/zw/qantra/tm/domain/workflows/WorkflowUtils.java @@ -1,19 +1,23 @@ package zw.qantra.tm.domain.workflows; import java.util.EnumSet; +import java.util.Map; import java.util.concurrent.CompletableFuture; -import io.nflow.engine.service.WorkflowInstanceInclude; +import io.nflow.engine.service.WorkflowInstanceInclude.*; import org.springframework.stereotype.Component; import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.workflow.instance.WorkflowInstance; import lombok.RequiredArgsConstructor; +import zw.qantra.tm.domain.dtos.StateResponse; +import zw.qantra.tm.utils.Utils; + +import static io.nflow.engine.service.WorkflowInstanceInclude.*; @Component @RequiredArgsConstructor - public class WorkflowUtils { private final WorkflowInstanceService workflowInstanceService; @@ -22,22 +26,35 @@ public class WorkflowUtils { while(!future.isDone()){ // check if the future is done WorkflowInstance instance = workflowInstanceService.getWorkflowInstance( - workflowId, EnumSet.of(WorkflowInstanceInclude.ACTIONS), 1L); + workflowId, + EnumSet.of(ACTIONS, ACTION_STATE_VARIABLES, CURRENT_STATE_VARIABLES), + 1L); try { - if(instance.state.equalsIgnoreCase(state)){ - future.complete(instance.getStateVariable("metadata")); - } Thread.sleep(pollingInterval); + if(instance.state.equalsIgnoreCase(state) && + instance.status.equals(WorkflowInstance.WorkflowInstanceStatus.manual)){ + future.complete(getStateResponse(instance, "success")); + } } catch (InterruptedException e) { future.completeExceptionally(e); } timeout -= pollingInterval; if(timeout <= 0){ - future.complete(instance); + future.complete(getStateResponse(instance, "Request timed out")); } } return future; } + + public StateResponse getStateResponse(WorkflowInstance instance, String message){ + return StateResponse.builder() + .state(instance.state) + .body(Utils.fromJson(instance.getStateVariable("body"), Map.class)) + .message(instance.getStateVariable("message")) + .workflowId(instance.id.toString()) + .externalId(instance.externalId) + .build(); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0799893..2bdfb20 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -49,13 +49,6 @@ jwt.expiration=86400000 logging.level.root=WARN # Email Configuration -spring.mail.host=smtp.gmail.com -spring.mail.port=587 -spring.mail.username=vusakhoza@gmail.com -spring.mail.password=Stikbanch@14 -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.properties.mail.smtp.starttls.required=true -spring.mail.properties.mail.smtp.connectiontimeout=5000 -spring.mail.properties.mail.smtp.timeout=5000 -spring.mail.properties.mail.smtp.writetimeout=5000 +infobip.url=https://z3m696.api.infobip.com/sms/2/text/advanced +infobip.apikey=0ede918b7c1110465a0518021521638c-ebc4fdb7-547d-413b-bbca-781949ac6597 +