Skip to content

Commit

Permalink
feat: saga pattern for consultant creation
Browse files Browse the repository at this point in the history
  • Loading branch information
tkuzynow committed Feb 29, 2024
1 parent 394dff4 commit a4f0709
Show file tree
Hide file tree
Showing 9 changed files with 356 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@
import de.caritas.cob.userservice.api.adapters.web.dto.CreateConsultantDTO;
import de.caritas.cob.userservice.api.adapters.web.dto.UpdateAdminConsultantDTO;
import de.caritas.cob.userservice.api.adapters.web.dto.UpdateConsultantDTO;
import de.caritas.cob.userservice.api.admin.service.consultant.create.ConsultantCreatorService;
import de.caritas.cob.userservice.api.admin.service.consultant.create.ConsultantCreateSaga;
import de.caritas.cob.userservice.api.admin.service.consultant.delete.ConsultantPreDeletionService;
import de.caritas.cob.userservice.api.admin.service.consultant.update.ConsultantUpdateService;
import de.caritas.cob.userservice.api.exception.httpresponses.DistributedTransactionException;
import de.caritas.cob.userservice.api.exception.httpresponses.DistributedTransactionInfo;
import de.caritas.cob.userservice.api.exception.httpresponses.NoContentException;
import de.caritas.cob.userservice.api.exception.httpresponses.NotFoundException;
import de.caritas.cob.userservice.api.helper.AuthenticatedUser;
Expand All @@ -26,7 +25,6 @@
import de.caritas.cob.userservice.api.port.out.ConsultantRepository;
import de.caritas.cob.userservice.api.port.out.SessionRepository;
import de.caritas.cob.userservice.api.service.appointment.AppointmentService;
import java.util.List;
import java.util.Map;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
Expand All @@ -40,17 +38,18 @@
public class ConsultantAdminService {

private final @NonNull ConsultantRepository consultantRepository;
private final @NonNull ConsultantCreatorService consultantCreatorService;
private final @NonNull ConsultantCreateSaga consultantCreateSaga;
private final @NonNull ConsultantUpdateService consultantUpdateService;
private final @NonNull ConsultantPreDeletionService consultantPreDeletionService;
private final @NonNull AppointmentService appointmentService;

private final @NonNull SessionRepository sessionRepository;

private final @NonNull AuthenticatedUser authenticatedUser;

private final @NonNull AccountManager accountManager;

private final @NonNull AppointmentService appointmentService;

/**
* Finds a {@link Consultant} by the given consultant id and throws a {@link NoContentException}
* if no consultant for given id exists.
Expand Down Expand Up @@ -90,36 +89,7 @@ private static String getDisplayNameFromUserMap(Map<String, Object> map) {
*/
public ConsultantAdminResponseDTO createNewConsultant(CreateConsultantDTO createConsultantDTO)
throws DistributedTransactionException {
Consultant newConsultant =
this.consultantCreatorService.createNewConsultant(createConsultantDTO);
List<TransactionalStep> completedSteps =
Lists.newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.CREATE_ACCOUNT_IN_ROCKETCHAT,
TransactionalStep.CREATE_CONSULTANT_IN_MARIADB);

ConsultantAdminResponseDTO consultantAdminResponseDTO =
ConsultantResponseDTOBuilder.getInstance(newConsultant).buildResponseDTO();

try {
this.appointmentService.createConsultant(consultantAdminResponseDTO);
} catch (Exception e) {
log.error(
"User with id {}, who has roles {}, has created a consultant with id {} but the appointment service returned an error: {}",
authenticatedUser.getUserId(),
authenticatedUser.getRoles(),
newConsultant.getId(),
e.getMessage());
this.consultantCreatorService.rollbackCreateNewConsultant(newConsultant);
throw new DistributedTransactionException(
e,
new DistributedTransactionInfo(
"createNewConsultant",
completedSteps,
TransactionalStep.CREATE_ACCOUNT_IN_CALCOM_OR_APPOINTMENTSERVICE));
}

return consultantAdminResponseDTO;
return this.consultantCreateSaga.createNewConsultant(createConsultantDTO);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ public enum TransactionalStep {

ROLLBACK_UPDATE_ROCKET_CHAT_USER_DISPLAY_NAME,

PATCH_APPOINTMENT_SERVICE_CONSULTANT;
PATCH_APPOINTMENT_SERVICE_CONSULTANT,
UPDATE_USER_PASSWORD_IN_KEYCLOAK,
UPDATE_USER_ROLES_IN_KEYCLOAK;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@
import static org.apache.commons.lang3.BooleanUtils.isTrue;
import static org.hibernate.validator.internal.util.CollectionHelper.asSet;

import com.google.common.collect.Lists;
import com.neovisionaries.i18n.LanguageCode;
import de.caritas.cob.userservice.api.adapters.keycloak.dto.KeycloakCreateUserResponseDTO;
import de.caritas.cob.userservice.api.adapters.rocketchat.RocketChatService;
import de.caritas.cob.userservice.api.adapters.web.dto.ConsultantAdminResponseDTO;
import de.caritas.cob.userservice.api.adapters.web.dto.CreateConsultantDTO;
import de.caritas.cob.userservice.api.adapters.web.dto.NotificationsSettingsDTO;
import de.caritas.cob.userservice.api.adapters.web.dto.UserDTO;
import de.caritas.cob.userservice.api.admin.service.consultant.ConsultantResponseDTOBuilder;
import de.caritas.cob.userservice.api.admin.service.consultant.TransactionalStep;
import de.caritas.cob.userservice.api.admin.service.consultant.validation.CreateConsultantDTOAbsenceInputAdapter;
import de.caritas.cob.userservice.api.admin.service.consultant.validation.UserAccountInputValidator;
import de.caritas.cob.userservice.api.admin.service.tenant.TenantAdminService;
import de.caritas.cob.userservice.api.exception.httpresponses.BadRequestException;
import de.caritas.cob.userservice.api.exception.httpresponses.CustomValidationHttpStatusException;
import de.caritas.cob.userservice.api.exception.httpresponses.InternalServerErrorException;
import de.caritas.cob.userservice.api.exception.httpresponses.DistributedTransactionException;
import de.caritas.cob.userservice.api.exception.httpresponses.DistributedTransactionInfo;
import de.caritas.cob.userservice.api.exception.httpresponses.customheader.HttpStatusExceptionReason;
import de.caritas.cob.userservice.api.exception.rocketchat.RocketChatLoginException;
import de.caritas.cob.userservice.api.facade.rollback.RollbackFacade;
import de.caritas.cob.userservice.api.helper.AuthenticatedUser;
import de.caritas.cob.userservice.api.helper.UserHelper;
Expand All @@ -29,6 +33,7 @@
import de.caritas.cob.userservice.api.port.out.IdentityClient;
import de.caritas.cob.userservice.api.service.ConsultantImportService.ImportRecord;
import de.caritas.cob.userservice.api.service.ConsultantService;
import de.caritas.cob.userservice.api.service.appointment.AppointmentService;
import de.caritas.cob.userservice.api.tenant.TenantContext;
import de.caritas.cob.userservice.tenantadminservice.generated.web.model.TenantDTO;
import java.util.Set;
Expand All @@ -37,14 +42,15 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* Creator class to generate new {@link Consultant} instances in database, keycloak and rocket chat.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ConsultantCreatorService {
public class ConsultantCreateSaga {

private final @NonNull IdentityClient identityClient;
private final @NonNull RocketChatService rocketChatService;
Expand All @@ -55,19 +61,25 @@ public class ConsultantCreatorService {

private final @NonNull RollbackFacade rollbackFacade;

@Value("${feature.appointment.enabled}")
private boolean appointmentFeatureEnabled;

@Value("${multitenancy.enabled}")
private boolean multiTenancyEnabled;

private final @NonNull AuthenticatedUser authenticatedUser;

private final @NonNull AppointmentService appointmentService;

/**
* Creates a new {@link Consultant} by {@link CreateConsultantDTO} in database, keycloak and
* rocket chat.
*
* @param createConsultantDTO the input used for creation
* @return the generated {@link Consultant}
*/
public Consultant createNewConsultant(CreateConsultantDTO createConsultantDTO) {
private Consultant createNewConsultantWithoutAppointment(
CreateConsultantDTO createConsultantDTO) {
assertLicensesNotExceeded();
this.userAccountInputValidator.validateAbsence(
new CreateConsultantDTOAbsenceInputAdapter(createConsultantDTO));
Expand All @@ -81,6 +93,46 @@ public Consultant createNewConsultant(CreateConsultantDTO createConsultantDTO) {
return createNewConsultant(consultantCreationInput, roles);
}

@Transactional
public ConsultantAdminResponseDTO createNewConsultant(CreateConsultantDTO createConsultantDTO)
throws DistributedTransactionException {
Consultant newConsultant = this.createNewConsultantWithoutAppointment(createConsultantDTO);

ConsultantAdminResponseDTO consultantAdminResponseDTO =
ConsultantResponseDTOBuilder.getInstance(newConsultant).buildResponseDTO();

if (appointmentFeatureEnabled) {
createConsultantInAppointmentServiceOrRollback(newConsultant, consultantAdminResponseDTO);
}
return consultantAdminResponseDTO;
}

private void createConsultantInAppointmentServiceOrRollback(
Consultant newConsultant, ConsultantAdminResponseDTO consultantAdminResponseDTO) {
try {
this.appointmentService.createConsultant(consultantAdminResponseDTO);
} catch (Exception e) {
log.error(
"User with id {}, who has roles {}, has created a consultant with id {} but the appointment service returned an error: {}",
authenticatedUser.getUserId(),
authenticatedUser.getRoles(),
newConsultant.getId(),
e.getMessage());
this.rollbackCreateNewConsultant(newConsultant);
throw new DistributedTransactionException(
e,
new DistributedTransactionInfo(
"createNewConsultant",
Lists.newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_ROLES_IN_KEYCLOAK,
TransactionalStep.CREATE_ACCOUNT_IN_ROCKETCHAT,
TransactionalStep.CREATE_CONSULTANT_IN_MARIADB),
TransactionalStep.CREATE_ACCOUNT_IN_CALCOM_OR_APPOINTMENTSERVICE));
}
}

private void validateTenantId(CreateConsultantDTO createConsultantDTO) {
if (authenticatedUser.isTenantSuperAdmin()) {
if (createConsultantDTO.getTenantId() == null) {
Expand Down Expand Up @@ -122,14 +174,87 @@ private Consultant createNewConsultant(
String keycloakUserId = createKeycloakUser(consultantCreationInput);

String password = userHelper.getRandomPassword();
identityClient.updatePassword(keycloakUserId, password);
roles.forEach(roleName -> identityClient.updateRole(keycloakUserId, roleName));
updateKeycloakPasswordOrRollback(consultantCreationInput, keycloakUserId, password);
updateKeyloakRolesOrRollback(roles, keycloakUserId, consultantCreationInput);

String rocketChatUserId =
createRocketChatUser(consultantCreationInput, keycloakUserId, password);
createRocketChatUserOrRollback(consultantCreationInput, keycloakUserId, password);

return consultantService.saveConsultant(
buildConsultant(consultantCreationInput, keycloakUserId, rocketChatUserId));
return createConsultantInMariaDBOrRollback(
consultantCreationInput, keycloakUserId, rocketChatUserId);
}

private void updateKeycloakPasswordOrRollback(
ConsultantCreationInput consultantCreationInput, String keycloakUserId, String password) {
try {
identityClient.updatePassword(keycloakUserId, password);
} catch (Exception e) {
log.error(
"Unable to update password or roles for user with encoded username {}",
consultantCreationInput.getEncodedUsername());
rollbackCreateNewConsultant(
buildConsultantDataWithUnknownRocketChatId(consultantCreationInput, keycloakUserId));
throw new DistributedTransactionException(
e,
DistributedTransactionInfo.builder()
.completedTransactionalOperations(
Lists.newArrayList(TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK))
.name("createConsultant")
.failedStep(TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK)
.build());
}
}

private void updateKeyloakRolesOrRollback(
Set<String> roles, String keycloakUserId, ConsultantCreationInput consultantCreationInput) {
try {
roles.forEach(roleName -> identityClient.updateRole(keycloakUserId, roleName));
} catch (Exception e) {
log.error(
"Unable to update roles for user with keycloak id {}. Initiating user rollback.",
keycloakUserId);
rollbackCreateNewConsultant(
buildConsultantDataWithUnknownRocketChatId(consultantCreationInput, keycloakUserId));
throw new DistributedTransactionException(
e,
DistributedTransactionInfo.builder()
.completedTransactionalOperations(
Lists.newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK))
.name("createConsultant")
.failedStep(TransactionalStep.UPDATE_USER_ROLES_IN_KEYCLOAK)
.build());
}
}

private Consultant createConsultantInMariaDBOrRollback(
ConsultantCreationInput consultantCreationInput,
String keycloakUserId,
String rocketChatUserId) {
Consultant consultant =
buildConsultant(consultantCreationInput, keycloakUserId, rocketChatUserId);
try {
return consultantService.saveConsultant(consultant);
} catch (Exception e) {
log.error(
"Unable to create consultant with encoded username {} in database. Rolling back keycloak and rocketchat user creation",
consultantCreationInput.getEncodedUsername());
rollbackCreateNewConsultant(consultant);

throw new DistributedTransactionException(
e,
DistributedTransactionInfo.builder()
.completedTransactionalOperations(
Lists.newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_ROLES_IN_KEYCLOAK,
TransactionalStep.CREATE_ACCOUNT_IN_ROCKETCHAT))
.name("createConsultant")
.failedStep(TransactionalStep.CREATE_CONSULTANT_IN_MARIADB)
.build());
}
}

private void assignCurrentTenantContext(CreateConsultantDTO createConsultantDTO) {
Expand Down Expand Up @@ -159,17 +284,44 @@ private String createKeycloakUser(ConsultantCreationInput consultantCreationInpu
return response.getUserId();
}

private String createRocketChatUser(
private String createRocketChatUserOrRollback(
ConsultantCreationInput consultantCreationInput, String keycloakUserId, String password) {
try {
return this.rocketChatService.getUserID(
consultantCreationInput.getEncodedUsername(), password, true);
} catch (RocketChatLoginException e) {
throw new InternalServerErrorException(
String.format("Unable to login user with id %s first time", keycloakUserId));
} catch (Exception e) {
log.error(
"Unable to create user with encoded username {} in rocketchat. Does this user already exist?",
consultantCreationInput.getEncodedUsername());
rollbackCreateNewConsultant(
buildConsultantDataWithUnknownRocketChatId(consultantCreationInput, keycloakUserId));
throw new DistributedTransactionException(
e,
DistributedTransactionInfo.builder()
.completedTransactionalOperations(
Lists.newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_ROLES_IN_KEYCLOAK))
.name("createConsultant")
.failedStep(TransactionalStep.CREATE_ACCOUNT_IN_ROCKETCHAT)
.build());
}
}

private static Consultant buildConsultantDataWithUnknownRocketChatId(
ConsultantCreationInput consultantCreationInput, String keycloakUserId) {
return Consultant.builder()
.id(keycloakUserId)
.tenantId(consultantCreationInput.getTenantId())
.rocketChatId("unknown")
.username(consultantCreationInput.getEncodedUsername())
.firstName(consultantCreationInput.getFirstName())
.lastName(consultantCreationInput.getLastName())
.email(consultantCreationInput.getEmail())
.build();
}

private Consultant buildConsultant(
ConsultantCreationInput consultantCreationInput,
String keycloakUserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@ public class RollbackFacade {
private final @NonNull DeleteUserAccountService deleteUserAccountService;

public void rollbackConsultantAccount(Consultant consultant) {
log.info("Rollback consultant account: {}", consultant);
log.info(
"Initiating rollback of consultant account. Consultant id: {}",
consultant.getId(),
consultant.getUsername());
consultant.setDeleteDate(LocalDateTime.now());
List<DeletionWorkflowError> deletionWorkflowErrors =
deleteUserAccountService.performConsultantDeletion(consultant);
if (nonNull(deletionWorkflowErrors) && !deletionWorkflowErrors.isEmpty()) {

deletionWorkflowErrors.stream()
.forEach(e -> log.error("Consultant delete workflow error: ", e));
.forEach(e -> log.error("Consultant delete error during rollback: ", e));
}
}
/**
Expand Down
Loading

0 comments on commit a4f0709

Please sign in to comment.