Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: saga pattern for consultant creation #688

Merged
merged 2 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.CreateConsultantSaga;
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 CreateConsultantSaga createConsultantSaga;
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 createConsultantSaga.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
@@ -1,5 +1,6 @@
package de.caritas.cob.userservice.api.admin.service.consultant.create;

import static com.google.common.collect.Lists.newArrayList;
import static de.caritas.cob.userservice.api.config.auth.UserRole.CONSULTANT;
import static de.caritas.cob.userservice.api.config.auth.UserRole.GROUP_CHAT_CONSULTANT;
import static de.caritas.cob.userservice.api.helper.json.JsonSerializationUtils.serializeToJsonString;
Expand All @@ -9,17 +10,20 @@
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,15 +42,17 @@
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 CreateConsultantSaga {

private static final String CREATE_CONSULTANT = "createConsultant";
private final @NonNull IdentityClient identityClient;
private final @NonNull RocketChatService rocketChatService;
private final @NonNull ConsultantService consultantService;
Expand All @@ -55,19 +62,18 @@ 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;

/**
* 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 final @NonNull AppointmentService appointmentService;

private Consultant createNewConsultantWithoutAppointment(
CreateConsultantDTO createConsultantDTO) {
assertLicensesNotExceeded();
this.userAccountInputValidator.validateAbsence(
new CreateConsultantDTOAbsenceInputAdapter(createConsultantDTO));
Expand All @@ -81,6 +87,56 @@ public Consultant createNewConsultant(CreateConsultantDTO createConsultantDTO) {
return createNewConsultant(consultantCreationInput, roles);
}

/**
* Creates a new {@link Consultant} by {@link CreateConsultantDTO} in database, keycloak and
* rocket chat, and optionally in the appointment service (calcom), provided the appointment
* feature is enabled.
*
* @param createConsultantDTO the input used for creation
* @return the generated {@link Consultant}
*/
@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,
DistributedTransactionInfo.builder()
.name("createNewConsultant")
.completedTransactionalOperations(
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))
.failedStep(TransactionalStep.CREATE_ACCOUNT_IN_CALCOM_OR_APPOINTMENTSERVICE)
.build());
}
}

private void validateTenantId(CreateConsultantDTO createConsultantDTO) {
if (authenticatedUser.isTenantSuperAdmin()) {
if (createConsultantDTO.getTenantId() == null) {
Expand Down Expand Up @@ -122,14 +178,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()
.name(CREATE_CONSULTANT)
.completedTransactionalOperations(
newArrayList(TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK))
.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(
newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK))
.name(CREATE_CONSULTANT)
.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()
.name(CREATE_CONSULTANT)
.completedTransactionalOperations(
newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_ROLES_IN_KEYCLOAK,
TransactionalStep.CREATE_ACCOUNT_IN_ROCKETCHAT))
.failedStep(TransactionalStep.CREATE_CONSULTANT_IN_MARIADB)
.build());
}
}

private void assignCurrentTenantContext(CreateConsultantDTO createConsultantDTO) {
Expand Down Expand Up @@ -159,17 +288,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(
newArrayList(
TransactionalStep.CREATE_ACCOUNT_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_PASSWORD_IN_KEYCLOAK,
TransactionalStep.UPDATE_USER_ROLES_IN_KEYCLOAK))
.name(CREATE_CONSULTANT)
.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
Loading