improvements on nflow workflows

This commit is contained in:
2025-09-14 11:50:52 +02:00
parent e13e8e1ffe
commit 1db27e22b2
11 changed files with 176 additions and 124 deletions

View File

@@ -155,6 +155,11 @@
<groupId>com.fasterxml.jackson.datatype</groupId> <groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId> <artifactId>jackson-datatype-jsr310</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-joda</artifactId>
<version>2.13.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -16,6 +16,7 @@ public class JacksonConfig {
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); objectMapper.registerModule(new JavaTimeModule());
objectMapper.registerModule(new JodaModule()); objectMapper.registerModule(new JodaModule());
return objectMapper; return objectMapper;
} }
} }

View File

@@ -1,19 +1,26 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import io.nflow.engine.service.WorkflowInstanceInclude;
import io.nflow.engine.service.WorkflowInstanceService; 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 io.nflow.engine.workflow.instance.WorkflowInstanceFactory;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.joda.time.DateTime;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.AuthResponse; import zw.qantra.tm.domain.dtos.AuthResponse;
import zw.qantra.tm.domain.dtos.LoginRequest; import zw.qantra.tm.domain.dtos.LoginRequest;
import zw.qantra.tm.domain.dtos.RegisterRequest; 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.services.UserService;
import zw.qantra.tm.domain.workflows.RegistrationWorkflow; import zw.qantra.tm.domain.workflows.RegistrationWorkflow;
import zw.qantra.tm.domain.workflows.WorkflowUtils; import zw.qantra.tm.domain.workflows.WorkflowUtils;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@@ -21,10 +28,10 @@ import java.util.concurrent.ExecutionException;
@RequestMapping("/auth") @RequestMapping("/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@CrossOrigin(origins = "*") @CrossOrigin(origins = "*")
public class AuthController { public class OnboardingController {
private final UserService userService; private final UserService userService;
private final WorkflowInstanceService workflowService; private final WorkflowInstanceService workflowInstanceService;
private final WorkflowInstanceFactory workflowInstanceFactory; private final WorkflowInstanceFactory workflowInstanceFactory;
private final WorkflowUtils workflowUtils; private final WorkflowUtils workflowUtils;
@@ -34,15 +41,43 @@ public class AuthController {
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(RegistrationWorkflow.TYPE) .setType(RegistrationWorkflow.TYPE)
.setExternalId(request.getUsername() + "-" + System.currentTimeMillis()) .setExternalId(request.getUsername() + "-" + System.currentTimeMillis())
.putStateVariable("metadata", request) .putStateVariable("body", request)
.build(); .build();
long workflowId = workflowService.insertWorkflowInstance(instance); long workflowId = workflowInstanceService.insertWorkflowInstance(instance);
CompletableFuture<Object> future = workflowUtils CompletableFuture<Object> future = workflowUtils
.waitForState(workflowId, "sendOtp", 30000, 1000); .waitForState(workflowId, "sendOtp", 30000, 1000);
Object result = future.get(); Object response = future.get();
return ResponseEntity.ok(Utils.toJson(result));
return ResponseEntity.ok(response);
}
@PostMapping("/verify")
public ResponseEntity<Object> 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<Object> future = workflowUtils
.waitForState(request.getWorkflowId(), "verifyOtp", 30000, 1000);
Object response = future.get();
return ResponseEntity.ok(response);
} }
@PostMapping("/login") @PostMapping("/login")

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -2,6 +2,7 @@ package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Otp; import zw.qantra.tm.domain.models.Otp;
import java.util.Optional; import java.util.Optional;
@@ -10,5 +11,6 @@ import java.util.Optional;
public interface OtpRepository extends JpaRepository<Otp, Long> { public interface OtpRepository extends JpaRepository<Otp, Long> {
Optional<Otp> findByUsernameAndOtpType(String username, String otpType); Optional<Otp> findByUsernameAndOtpType(String username, String otpType);
Optional<Otp> findByUsernameAndOtpTypeAndOtp(String username, String otpType, String otp); Optional<Otp> findByUsernameAndOtpTypeAndOtp(String username, String otpType, String otp);
Optional<Otp> findByUsernameAndOtpTypeAndOtpAndOtpStatus(String username, String otpType, String otp, Status status);
void deleteByUsernameAndOtpType(String username, String otpType); void deleteByUsernameAndOtpType(String username, String otpType);
} }

View File

@@ -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;
}
}
}

View File

@@ -7,7 +7,6 @@ import zw.qantra.tm.domain.models.Otp;
import zw.qantra.tm.domain.repositories.OtpRepository; import zw.qantra.tm.domain.repositories.OtpRepository;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -21,9 +20,6 @@ public class OtpService {
private final OtpRepository otpRepository; private final OtpRepository otpRepository;
public Otp generateOtp(String username, String otpType) { public Otp generateOtp(String username, String otpType) {
// Delete any existing OTP for this username and type
otpRepository.deleteByUsernameAndOtpType(username, otpType);
String otpCode = generateRandomOtp(); String otpCode = generateRandomOtp();
Otp otp = Otp.builder() Otp otp = Otp.builder()
@@ -37,7 +33,8 @@ public class OtpService {
} }
public boolean verifyOtp(String username, String otpCode, String otpType) { public boolean verifyOtp(String username, String otpCode, String otpType) {
Optional<Otp> otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtp(username, otpType, otpCode); Optional<Otp> otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtpAndOtpStatus(
username, otpType, otpCode, Status.ACTIVE);
if (otpOpt.isEmpty()) { if (otpOpt.isEmpty()) {
return false; return false;

View File

@@ -3,14 +3,15 @@ package zw.qantra.tm.domain.workflows;
import io.nflow.engine.workflow.definition.WorkflowStateType; import io.nflow.engine.workflow.definition.WorkflowStateType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import io.nflow.engine.workflow.definition.NextAction; import io.nflow.engine.workflow.definition.NextAction;
import io.nflow.engine.workflow.definition.StateExecution; import io.nflow.engine.workflow.definition.StateExecution;
import io.nflow.engine.workflow.definition.WorkflowDefinition; import io.nflow.engine.workflow.definition.WorkflowDefinition;
import zw.qantra.tm.domain.dtos.EmailRequest; 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.models.Otp;
import zw.qantra.tm.domain.services.EmailService;
import zw.qantra.tm.domain.services.OtpService; import zw.qantra.tm.domain.services.OtpService;
import zw.qantra.tm.domain.services.UserService; import zw.qantra.tm.domain.services.UserService;
import io.nflow.engine.workflow.curated.State; 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.ConstraintViolation;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import zw.qantra.tm.rest.RestService;
import org.springframework.http.HttpHeaders;
import java.util.LinkedHashMap;
import java.util.Set; import java.util.Set;
@Component @Component
public class RegistrationWorkflow extends WorkflowDefinition { public class RegistrationWorkflow extends WorkflowDefinition {
Logger log = org.slf4j.LoggerFactory.getLogger(RegistrationWorkflow.class); 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 @Autowired
private UserService userService; private UserService userService;
@Autowired @Autowired
@@ -44,16 +37,35 @@ public class RegistrationWorkflow extends WorkflowDefinition {
@Autowired @Autowired
private OtpService otpService; private OtpService otpService;
@Autowired @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() { public RegistrationWorkflow() {
super(TYPE, BEGIN, FAILED); super(TYPE, BEGIN, FAILED);
permit(BEGIN, REGISTER); permit(BEGIN, REGISTER);
permit(REGISTER, SEND_OTP); permit(REGISTER, SEND_OTP);
permit(SEND_OTP, VERIFY_OTP, RESEND_OTP); permit(SEND_OTP, VERIFY_OTP);
permit(RESEND_OTP, VERIFY_OTP, FAILED); permit(VERIFY_OTP, OTP_SUCCESS, FAILED);
permit(VERIFY_OTP, SET_PIN, FAILED); permit(RESEND_OTP, OTP_SUCCESS, FAILED);
permit(OTP_SUCCESS, SET_PIN, FAILED);
permit(SET_PIN, UPDATE_ERP); permit(SET_PIN, UPDATE_ERP);
permit(UPDATE_ERP, DONE); permit(UPDATE_ERP, DONE);
} }
@@ -65,7 +77,7 @@ public class RegistrationWorkflow extends WorkflowDefinition {
// register validates most of the initial registration details // register validates most of the initial registration details
public NextAction register(StateExecution execution) { public NextAction register(StateExecution execution) {
RegisterRequest payload = execution.getVariable("metadata", RegisterRequest.class); RegisterRequest payload = execution.getVariable("body", RegisterRequest.class);
if(payload == null) if(payload == null)
return NextAction.moveToState(FAILED, "No payload provided"); return NextAction.moveToState(FAILED, "No payload provided");
@@ -82,65 +94,71 @@ public class RegistrationWorkflow extends WorkflowDefinition {
} }
// todo: add this to the appropriate action // 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()); return NextAction.moveToState(SEND_OTP, "Initial registration success for: " + payload.getUsername());
} }
public void sendOtp(StateExecution execution) { public void sendOtp(StateExecution execution) {
try { try {
RegisterRequest registerRequest = execution.getVariable("metadata", RegisterRequest.class); RegisterRequest registerRequest = execution.getVariable("body", RegisterRequest.class);
String username = registerRequest.getUsername(); String username = registerRequest.getUsername();
String email = registerRequest.getEmail(); String email = registerRequest.getEmail();
// Generate OTP // Generate OTP
Otp otp = otpService.generateOtp(username, "REGISTRATION"); Otp otp = otpService.generateOtp(username, "REGISTRATION");
// Create email content String string = getSMSString(registerRequest.getPhone(), otp.getOtp());
String subject = "Your Verification Code"; HttpHeaders headers = new HttpHeaders();
String body = String.format( headers.add("Authorization", "App " + infobipApiKey);
"<html><body>" + headers.add("Content-Type", "application/json");
"<h2>Email Verification</h2>" + headers.add("Accept", "application/json");
"<p>Hello %s,</p>" + // LinkedHashMap response = restService.postAs(infobipUrl, string, headers, LinkedHashMap.class);
"<p>Your verification code is: <strong>%s</strong></p>" +
"<p>This code will expire in 10 minutes.</p>" +
"<p>If you didn't request this code, please ignore this email.</p>" +
"</body></html>",
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);
}
log.info("otp generated successfully: {}", otp.getOtp());
} catch (Exception e) { } catch (Exception e) {
log.error("Error sending OTP: {}", e.getMessage(), e); log.error("Error sending OTP: {}", e.getMessage(), e);
} }
} }
public NextAction verifyOtp(StateExecution execution) { 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) { 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()); return NextAction.stopInState(UPDATE_ERP, "PIN set successfully for: " + response.getUsername());
} }
public NextAction updateErp(StateExecution execution) { 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()); 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);
}
} }

View File

@@ -1,19 +1,23 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.workflows;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import io.nflow.engine.service.WorkflowInstanceInclude; import io.nflow.engine.service.WorkflowInstanceInclude.*;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstance;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import zw.qantra.tm.domain.dtos.StateResponse;
import zw.qantra.tm.utils.Utils;
import static io.nflow.engine.service.WorkflowInstanceInclude.*;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class WorkflowUtils { public class WorkflowUtils {
private final WorkflowInstanceService workflowInstanceService; private final WorkflowInstanceService workflowInstanceService;
@@ -22,22 +26,35 @@ public class WorkflowUtils {
while(!future.isDone()){ // check if the future is done while(!future.isDone()){ // check if the future is done
WorkflowInstance instance = workflowInstanceService.getWorkflowInstance( WorkflowInstance instance = workflowInstanceService.getWorkflowInstance(
workflowId, EnumSet.of(WorkflowInstanceInclude.ACTIONS), 1L); workflowId,
EnumSet.of(ACTIONS, ACTION_STATE_VARIABLES, CURRENT_STATE_VARIABLES),
1L);
try { try {
if(instance.state.equalsIgnoreCase(state)){
future.complete(instance.getStateVariable("metadata"));
}
Thread.sleep(pollingInterval); Thread.sleep(pollingInterval);
if(instance.state.equalsIgnoreCase(state) &&
instance.status.equals(WorkflowInstance.WorkflowInstanceStatus.manual)){
future.complete(getStateResponse(instance, "success"));
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
future.completeExceptionally(e); future.completeExceptionally(e);
} }
timeout -= pollingInterval; timeout -= pollingInterval;
if(timeout <= 0){ if(timeout <= 0){
future.complete(instance); future.complete(getStateResponse(instance, "Request timed out"));
} }
} }
return future; 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();
}
} }

View File

@@ -49,13 +49,6 @@ jwt.expiration=86400000
logging.level.root=WARN logging.level.root=WARN
# Email Configuration # Email Configuration
spring.mail.host=smtp.gmail.com infobip.url=https://z3m696.api.infobip.com/sms/2/text/advanced
spring.mail.port=587 infobip.apikey=0ede918b7c1110465a0518021521638c-ebc4fdb7-547d-413b-bbca-781949ac6597
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