Skip to content

Commit

Permalink
feat: 1301 update smart save functionality to handle multiple users e…
Browse files Browse the repository at this point in the history
…diting the same form (#1342)

Co-authored-by: Ricardo Campos <[email protected]>
  • Loading branch information
craigyu and Ricardo Campos authored Jul 8, 2024
1 parent bef590d commit 06c5807
Show file tree
Hide file tree
Showing 16 changed files with 276 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public record SaveSeedlotFormDtoClassA(
@Schema(
description = "The JSON object that stores the progress on the front-end",
example = "any json object")
JsonNode progressStatus) {}
JsonNode progressStatus,
@Schema(description = "The amount of time this data have been revised", example = "46")
Integer revisionCount) {}
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,14 @@ public ResponseEntity<SeedlotStatusResponseDto> submitSeedlotForm(
@ApiResponse(
responseCode = "401",
description = "Access token is missing or invalid",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "403",
description = "Client id requested not present on user profile and roles.",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "409",
description = "Data conflict while saving",
content = @Content(schema = @Schema(implementation = Void.class)))
})
@RoleAccessConfig({"SPAR_TSC_ADMIN", "SPAR_MINISTRY_ORCHARD", "SPAR_NONMINISTRY_ORCHARD"})
Expand Down Expand Up @@ -501,13 +509,17 @@ public ResponseEntity<Void> saveFormProgressClassA(
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved."),
@ApiResponse(
responseCode = "404",
description = "Seedlot form progress not found",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "401",
description = "Access token is missing or invalid",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "403",
description = "Client id requested not present on user profile and roles.",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "404",
description = "Seedlot form progress not found",
content = @Content(schema = @Schema(implementation = Void.class)))
})
@RoleAccessConfig({"SPAR_TSC_ADMIN", "SPAR_MINISTRY_ORCHARD", "SPAR_NONMINISTRY_ORCHARD"})
Expand All @@ -534,13 +546,17 @@ public SaveSeedlotFormDtoClassA getFormProgressClassA(
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Successfully retrieved."),
@ApiResponse(
responseCode = "404",
description = "Seedlot form progress not found",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "401",
description = "Access token is missing or invalid",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "403",
description = "Client id requested not present on user profile and roles.",
content = @Content(schema = @Schema(implementation = Void.class))),
@ApiResponse(
responseCode = "404",
description = "Seedlot form progress not found",
content = @Content(schema = @Schema(implementation = Void.class)))
})
@RoleAccessConfig({"SPAR_TSC_ADMIN", "SPAR_MINISTRY_ORCHARD", "SPAR_NONMINISTRY_ORCHARD"})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package ca.bc.gov.backendstartapi.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;

/** This class represents a revision count mismatch exception. */
@ResponseStatus(value = HttpStatus.CONFLICT)
public class RevisionCountMismatchException extends ResponseStatusException {

/** This class represents a revision count mismatch exception. */
public RevisionCountMismatchException() {
super(HttpStatus.CONFLICT, String.format("Request rejected due to revision count mismatch"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public record UserInfo(
@NonNull List<String> clientIds,
@NonNull String jwtToken) {

private static final String devClientNumber = "00011223";

/** Ensure immutability for the user's roles. */
public UserInfo {
if (identityProvider.equals(IdentityProvider.IDIR)) {
Expand All @@ -70,7 +72,16 @@ public static UserInfo createDevUser() {
null,
IdentityProvider.IDIR,
Set.of(),
List.of("00011223"),
List.of(devClientNumber),
"abcdef123456");
}

/**
* Getter for devClientNumber.
*
* @return the mocked client number
*/
public static String getDevClientNumber() {
return devClientNumber;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
import ca.bc.gov.backendstartapi.dto.SaveSeedlotFormDtoClassA;
import ca.bc.gov.backendstartapi.entity.SaveSeedlotProgressEntityClassA;
import ca.bc.gov.backendstartapi.entity.seedlot.Seedlot;
import ca.bc.gov.backendstartapi.exception.ClientIdForbiddenException;
import ca.bc.gov.backendstartapi.exception.JsonParsingException;
import ca.bc.gov.backendstartapi.exception.RevisionCountMismatchException;
import ca.bc.gov.backendstartapi.exception.SeedlotFormProgressNotFoundException;
import ca.bc.gov.backendstartapi.exception.SeedlotNotFoundException;
import ca.bc.gov.backendstartapi.repository.SaveSeedlotProgressRepositoryClassA;
import ca.bc.gov.backendstartapi.repository.SeedlotRepository;
import ca.bc.gov.backendstartapi.security.LoggedUserService;
import ca.bc.gov.backendstartapi.security.UserInfo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand Down Expand Up @@ -37,6 +40,10 @@ public void saveFormClassA(@NonNull String seedlotNumber, SaveSeedlotFormDtoClas
Seedlot relatedSeedlot =
seedlotRepository.findById(seedlotNumber).orElseThrow(SeedlotNotFoundException::new);

String seedlotApplicantClientNumber = relatedSeedlot.getApplicantClientNumber();

verifySeedlotAccessPrivilege(seedlotApplicantClientNumber);

Optional<SaveSeedlotProgressEntityClassA> optionalEntityToSave =
saveSeedlotProgressRepositoryClassA.findById(seedlotNumber);

Expand All @@ -57,7 +64,21 @@ public void saveFormClassA(@NonNull String seedlotNumber, SaveSeedlotFormDtoClas
parsedProgressStatus,
loggedUserService.createAuditCurrentUser());
} else {
SparLog.warn(
// Revision Count verification
Integer prevRevCount = data.revisionCount();
Integer currRevCount = optionalEntityToSave.get().getRevisionCount();

if (!prevRevCount.equals(currRevCount)) {
// Conflict detected
SparLog.info(
"Save progress failed due to revision count mismatch, prev revision count: {}, curr"
+ " revision count: {}",
prevRevCount,
currRevCount);
throw new RevisionCountMismatchException();
}

SparLog.info(
"A-class seedlot progress for seedlot number {} exists, replacing with new values",
seedlotNumber);
entityToSave = optionalEntityToSave.get();
Expand All @@ -84,20 +105,30 @@ public SaveSeedlotFormDtoClassA getFormClassA(@NonNull String seedlotNumber) {

if (form.isPresent()) {
SparLog.info("A-class seedlot progress found for seedlot number {}", seedlotNumber);

String seedlotApplicantClientNumber = form.get().getSeedlot().getApplicantClientNumber();
verifySeedlotAccessPrivilege(seedlotApplicantClientNumber);
}

return form.map(
savedEntity ->
new SaveSeedlotFormDtoClassA(
mapper.convertValue(savedEntity.getAllStepData(), JsonNode.class),
mapper.convertValue(savedEntity.getProgressStatus(), JsonNode.class)))
mapper.convertValue(savedEntity.getProgressStatus(), JsonNode.class),
form.get().getRevisionCount()))
.orElseThrow(SeedlotFormProgressNotFoundException::new);
}

/** Retrieves the progress_status column then return it as a json object. */
public JsonNode getFormStatusClassA(String seedlotNumber) {
SparLog.info(
"Retrieving A-class seedlot progress status for seedlot number {}", seedlotNumber);
SparLog.info("Retrieving A-class seedlot progress status for seedlot number {}", seedlotNumber);

Seedlot relatedSeedlot =
seedlotRepository.findById(seedlotNumber).orElseThrow(SeedlotNotFoundException::new);

String seedlotApplicantClientNumber = relatedSeedlot.getApplicantClientNumber();
verifySeedlotAccessPrivilege(seedlotApplicantClientNumber);

ObjectMapper mapper = new ObjectMapper();

Optional<Object> form = saveSeedlotProgressRepositoryClassA.getStatusById(seedlotNumber);
Expand All @@ -120,4 +151,20 @@ public JsonNode getFormStatusClassA(String seedlotNumber) {
throw new JsonParsingException();
}
}

/**
* Verify if the service initiator has the correct access.
*
* @param seedlot to verify
* @throw an {@link ClientIdForbiddenException}
*/
private void verifySeedlotAccessPrivilege(String seedlotApplicantClientNumber) {
Optional<UserInfo> userInfo = loggedUserService.getLoggedUserInfo();

if (userInfo.isEmpty() || !userInfo.get().clientIds().contains(seedlotApplicantClientNumber)) {
SparLog.info(
"Request denied due to user not having client id: {}", seedlotApplicantClientNumber);
throw new ClientIdForbiddenException();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ void getSeedlotFormProgress_notFound_shouldThrowException() throws Exception {
@DisplayName("Get seedlot form progress should succeed")
void getSeedlotFormProgress_shouldSucceed() throws Exception {
when(saveSeedlotFormService.getFormClassA(any()))
.thenReturn(new SaveSeedlotFormDtoClassA(null, null));
.thenReturn(new SaveSeedlotFormDtoClassA(null, null, 1));

mockMvc
.perform(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ca.bc.gov.backendstartapi.repository.SaveSeedlotProgressRepositoryClassA;
import ca.bc.gov.backendstartapi.repository.SeedlotRepository;
import ca.bc.gov.backendstartapi.security.LoggedUserService;
import ca.bc.gov.backendstartapi.security.UserInfo;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Optional;
Expand All @@ -35,11 +36,17 @@ class SaveSeedlotFormServiceTest {

private static final String SEEDLOT_NUMBER = "678123";

private Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER);

@BeforeEach
void setup() {
saveSeedlotFormService =
new SaveSeedlotFormService(
saveSeedlotProgressRepositoryClassA, seedlotRepository, loggedUserService);

when(loggedUserService.getLoggedUserInfo()).thenReturn(Optional.of(UserInfo.createDevUser()));

testSeedlot.setApplicantClientNumber(UserInfo.getDevClientNumber());
}

@Test
Expand All @@ -51,7 +58,7 @@ void saveSeedlotProgress_seedlotMissing_shouldFail() throws Exception {

JsonNode progressStatus = new ObjectMapper().readTree("{ \"f2\" : \"v2\" } ");

SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus);
SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus, 1);

ResponseStatusException expectedException =
assertThrows(
Expand All @@ -64,7 +71,6 @@ void saveSeedlotProgress_seedlotMissing_shouldFail() throws Exception {
@Test
@DisplayName("Save seedlot progress with missing seedlot should succeed.")
void saveSeedlotProgress_shouldSucceed() throws Exception {
Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER);

when(seedlotRepository.findById(any())).thenReturn(Optional.of(testSeedlot));

Expand All @@ -75,7 +81,7 @@ void saveSeedlotProgress_shouldSucceed() throws Exception {

JsonNode progressStatus = new ObjectMapper().readTree("{ \"f2\" : \"v2\" } ");

SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus);
SaveSeedlotFormDtoClassA saveDto = new SaveSeedlotFormDtoClassA(allStepData, progressStatus, 1);

// Testing a void function, if there is no error then it means success.
assertDoesNotThrow(() -> saveSeedlotFormService.saveFormClassA(SEEDLOT_NUMBER, saveDto));
Expand All @@ -97,7 +103,6 @@ void getSeedlotProgress_noSeedlotNumber_shouldFail() throws Exception {
@Test
@DisplayName("Get seedlot progress should succeed.")
void getSeedlotProgress_shouldSucceed() throws Exception {
Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER);

when(seedlotRepository.findById(any())).thenReturn(Optional.of(testSeedlot));

Expand Down Expand Up @@ -126,7 +131,6 @@ void getSeedlotProgressStatus_noSeedlotNumber_shouldFail() throws Exception {
@Test
@DisplayName("Get seedlot progress status should succeed.")
void getSeedlotProgressStatus_shouldSucceed() throws Exception {
Seedlot testSeedlot = new Seedlot(SEEDLOT_NUMBER);

when(seedlotRepository.findById(any())).thenReturn(Optional.of(testSeedlot));

Expand Down
6 changes: 5 additions & 1 deletion frontend/src/styles/custom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,11 @@ label.#{vars.$bcgov-prefix}--label--disabled {
padding: 0;
}

.danger-tertiary-btn{
.danger-tertiary-btn {
border-color: var(--#{vars.$bcgov-prefix}-button-danger-secondary, colors.$red-70);
color: var(--#{vars.$bcgov-prefix}-button-danger-secondary, colors.$red-70);
}

.ul-disc {
list-style-type: disc;
}
3 changes: 2 additions & 1 deletion frontend/src/types/SeedlotType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ export type TscSeedlotEditPayloadType = SeedlotAClassSubmitType & {

export type SeedlotProgressPayloadType = {
allStepData: AllStepData,
progressStatus: ProgressIndicatorConfig
progressStatus: ProgressIndicatorConfig,
revisionCount: number
};

export type SeedlotCalculationsResultsType = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';

import { EmptyMultiOptObj } from '../../../shared-constants/shared-constants';
import MultiOptionsObj from '../../../types/MultiOptionsObject';
import {
Expand Down Expand Up @@ -96,8 +98,21 @@ export const smartSaveText = {
loading: 'Saving...',
error: 'Save changes failed',
idle: 'Save changes',
reload: 'Reload form',
success: 'Changes saved!',
suggestion: 'Your recent changes could not be saved. Please try saving the form manually to keep all of your changes.'
suggestion: 'Your recent changes could not be saved. Please try saving the form manually to keep all of your changes.',
conflictTitle: 'Conflict detected',
conflictSuggestion: (
<div className="conflict-suggestion-div">
Another user has updated this form. Please reload the page to view the latest information
<br />
<ul className="ul-disc">
<li>Saving and submitting are temporarily disabled to prevent overwriting</li>
<li>Reload the page to continue editing without losing further data</li>
<li>Any unsaved changes will be lost</li>
</ul>
</div>
)
};

export const emptyCollectionStep: CollectionFormSubmitType = {
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/views/Seedlot/ContextContainerClassA/context.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { createContext } from 'react';
import { UseMutationResult } from '@tanstack/react-query';
import { UseMutationResult, UseQueryResult } from '@tanstack/react-query';
import { AxiosResponse } from 'axios';

import { EmptyMultiOptObj } from '../../../shared-constants/shared-constants';
import MultiOptionsObj from '../../../types/MultiOptionsObject';
import {
RichSeedlotType, SeedlotAClassSubmitType,
SeedlotCalculationsResultsType, SeedlotType
SeedlotCalculationsResultsType, SeedlotProgressPayloadType, SeedlotType
} from '../../../types/SeedlotType';

import { AllStepData, AreaOfUseDataType, ProgressIndicatorConfig } from './definitions';
Expand Down Expand Up @@ -73,7 +73,8 @@ export type ClassAContextType = {
areaOfUseData: AreaOfUseDataType,
setAreaOfUseData: React.Dispatch<React.SetStateAction<AreaOfUseDataType>>,
isCalculatingPt: boolean,
setIsCalculatingPt: Function
setIsCalculatingPt: Function,
getFormDraftQuery: UseQueryResult<SeedlotProgressPayloadType, unknown>
}

const ClassAContext = createContext<ClassAContextType>({
Expand Down Expand Up @@ -122,7 +123,8 @@ const ClassAContext = createContext<ClassAContextType>({
areaOfUseData: {} as AreaOfUseDataType,
setAreaOfUseData: () => { },
isCalculatingPt: false,
setIsCalculatingPt: () => { }
setIsCalculatingPt: () => { },
getFormDraftQuery: {} as UseQueryResult<SeedlotProgressPayloadType, unknown>
});

export default ClassAContext;
Loading

0 comments on commit 06c5807

Please sign in to comment.