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>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-joda</artifactId>
<version>2.13.1</version>
</dependency>
</dependencies>
<build>

View File

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

View File

@@ -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<Object> 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<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")

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.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<Otp, Long> {
Optional<Otp> findByUsernameAndOtpType(String username, String otpType);
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);
}

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 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<Otp> otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtp(username, otpType, otpCode);
Optional<Otp> otpOpt = otpRepository.findByUsernameAndOtpTypeAndOtpAndOtpStatus(
username, otpType, otpCode, Status.ACTIVE);
if (otpOpt.isEmpty()) {
return false;

View File

@@ -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(
"<html><body>" +
"<h2>Email Verification</h2>" +
"<p>Hello %s,</p>" +
"<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);
}
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);
}
}

View File

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

View File

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