Skip to content

Commit a888a6e

Browse files
fix(#2395): prevent contact deletion when linked to other systems (#2402)
* fix(#2395): prevent contact deletion when linked to other systems
1 parent f9ea457 commit a888a6e

13 files changed

Lines changed: 663 additions & 21 deletions

frontend/cypress/e2e/pages/ClientDetailsPage.cy.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,45 @@ describe("Client Details Page", () => {
19171917
cy.get("#contact-null-SaveBtn").shadow().find("button").should("be.enabled");
19181918
});
19191919
});
1920+
1921+
describe("delete a contact that is in use by another system", () => {
1922+
beforeEach(function () {
1923+
init.call(this);
1924+
});
1925+
1926+
it("shows the backend error message in the toast", () => {
1927+
cy.intercept("PATCH", "/api/clients/details/*", {
1928+
statusCode: 409,
1929+
body:
1930+
"You can't delete this contact yet because it's being used by EMS, GAS2, LEXIS, " +
1931+
"or SCS. Remove it from the other system first, then try again.",
1932+
delay: 250,
1933+
}).as("saveClientDetails");
1934+
1935+
cy.visit("/clients/details/p");
1936+
1937+
// Switch to the Contacts tab
1938+
cy.get("#tab-contacts").click();
1939+
1940+
// Clicks to expand the accordion
1941+
cy.get("#contact-10 [slot='title']").click();
1942+
1943+
cy.get("#contact-10-EditBtn").click();
1944+
1945+
// Delete contact
1946+
cy.get("#contact-10-DeleteBtn").click();
1947+
cy.get("#modal-delete .cds--modal-submit-btn").filter(":visible").click();
1948+
1949+
cy.wait("@saveClientDetails");
1950+
1951+
cy.get("cds-toast-notification[kind='error']").should("be.visible");
1952+
1953+
cy.get("cds-toast-notification[kind='error']").contains(
1954+
"You can't delete this contact yet because it's being used by EMS, GAS2, LEXIS, " +
1955+
"or SCS.",
1956+
);
1957+
});
1958+
});
19201959
});
19211960
});
19221961

frontend/src/pages/ClientDetailsPage.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,10 @@ const operateContact =
779779
kind: "Error",
780780
active: true,
781781
handler: () => {},
782-
message: `Failed to ${action.infinitive} contact`,
782+
message:
783+
error.response?.status === 409 && typeof error.response.data === "string"
784+
? error.response.data
785+
: `Failed to ${action.infinitive} contact`,
783786
toastTitle: undefined,
784787
};
785788
toastBus.emit(toastNotification);

legacy/src/main/java/ca/bc/gov/app/controller/ClientContactController.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.springframework.http.HttpStatus;
99
import org.springframework.http.MediaType;
1010
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PathVariable;
1112
import org.springframework.web.bind.annotation.PostMapping;
1213
import org.springframework.web.bind.annotation.RequestBody;
1314
import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,6 +18,9 @@
1718
import reactor.core.publisher.Flux;
1819
import reactor.core.publisher.Mono;
1920

21+
/**
22+
* Exposes the endpoints used to create, search and validate forest client contacts.
23+
*/
2024
@RestController
2125
@Slf4j
2226
@RequestMapping(value = "/api/contacts", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -26,13 +30,28 @@ public class ClientContactController {
2630

2731
private final ClientContactService service;
2832

33+
/**
34+
* Saves the provided forest client contact.
35+
*
36+
* @param dto the contact to be saved
37+
* @return a {@link Mono} emitting the client number of the saved contact
38+
*/
2939
@PostMapping
3040
@ResponseStatus(HttpStatus.CREATED)
3141
public Mono<String> saveLocation(@RequestBody ForestClientContactDto dto) {
3242
log.info("Receiving request to save contact for {}: {}", dto.clientNumber(), dto.contactName());
3343
return service.saveAndGetIndex(dto);
3444
}
3545

46+
/**
47+
* Searches for forest client contacts matching the provided name, email and phone.
48+
*
49+
* @param firstName the first name of the contact
50+
* @param lastName the last name of the contact
51+
* @param email the email address of the contact
52+
* @param phone the phone number of the contact
53+
* @return a {@link Flux} emitting the matching contacts
54+
*/
3655
@GetMapping("/search")
3756
public Flux<ForestClientContactDto> findIndividuals(
3857
@RequestParam String firstName,
@@ -45,4 +64,18 @@ public Flux<ForestClientContactDto> findIndividuals(
4564
return service.search(firstName, lastName, email, phone);
4665
}
4766

67+
/**
68+
* Checks whether a contact is being used (referenced) by another system, such as EMS, GAS2,
69+
* LEXIS, or SCS.
70+
*
71+
* @param contactId the id of the contact to check
72+
* @return a {@link Mono} emitting {@code true} if the contact is in use, {@code false}
73+
* otherwise
74+
*/
75+
@GetMapping("/{contactId}/in-use")
76+
public Mono<Boolean> isContactInUse(@PathVariable Long contactId) {
77+
log.info("Receiving request to check if contact {} is in use by another system", contactId);
78+
return service.isContactInUse(contactId);
79+
}
80+
4881
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package ca.bc.gov.app.entity;
2+
3+
import static ca.bc.gov.app.ApplicationConstants.ORACLE_ATTRIBUTE_SCHEMA;
4+
5+
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.Size;
7+
import java.time.LocalDate;
8+
import java.time.LocalDateTime;
9+
import lombok.AllArgsConstructor;
10+
import lombok.Builder;
11+
import lombok.Data;
12+
import lombok.NoArgsConstructor;
13+
import lombok.With;
14+
import org.springframework.data.annotation.Id;
15+
import org.springframework.data.relational.core.mapping.Column;
16+
import org.springframework.data.relational.core.mapping.Table;
17+
18+
/**
19+
* Represents a record of the {@code THE.SCALE_SITE_CONTACT} table.
20+
*
21+
* <p>This table links a client contact to a scale site and is used by other systems (EMS, GAS2,
22+
* LEXIS, SCS) to reference client contacts. It is mainly used to check whether a client contact
23+
* is still in use before allowing it to be deleted.</p>
24+
*/
25+
@NoArgsConstructor
26+
@AllArgsConstructor
27+
@Data
28+
@Builder
29+
@With
30+
@Table(name = "SCALE_SITE_CONTACT", schema = ORACLE_ATTRIBUTE_SCHEMA)
31+
public class ScaleSiteContactEntity {
32+
33+
@Id
34+
@Column("CLIENT_CONTACT_ID")
35+
private Long clientContactId;
36+
37+
@Column("SCALE_SITE_ID_NMBR")
38+
@NotNull
39+
@Size(min = 1, max = 4)
40+
private String scaleSiteIdNumber;
41+
42+
@Column("CONTACT_ROLE_DESCRIPTION")
43+
@NotNull
44+
@Size(min = 1, max = 40)
45+
private String contactRoleDescription;
46+
47+
@Column("PRIMARY_CONTACT_IND")
48+
@NotNull
49+
@Size(min = 1, max = 1)
50+
private String primaryContactInd;
51+
52+
@Column("SITE_INFORMATION_ACCESS_IND")
53+
@NotNull
54+
@Size(min = 1, max = 1)
55+
private String siteInformationAccessInd;
56+
57+
@Column("EFFECTIVE_DATE")
58+
@NotNull
59+
private LocalDate effectiveDate;
60+
61+
@Column("EXPIRY_DATE")
62+
private LocalDate expiryDate;
63+
64+
@Column("ENTRY_TIMESTAMP")
65+
@NotNull
66+
private LocalDateTime createdAt;
67+
68+
@Column("ENTRY_USERID")
69+
@NotNull
70+
@Size(min = 1, max = 30)
71+
private String createdBy;
72+
73+
@Column("UPDATE_TIMESTAMP")
74+
@NotNull
75+
private LocalDateTime updatedAt;
76+
77+
@Column("UPDATE_USERID")
78+
@NotNull
79+
@Size(min = 1, max = 30)
80+
private String updatedBy;
81+
82+
@Column("REVISION_COUNT")
83+
@NotNull
84+
private Long revision;
85+
86+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package ca.bc.gov.app.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.bind.annotation.ResponseStatus;
5+
import org.springframework.web.server.ResponseStatusException;
6+
7+
/**
8+
* Exception thrown when an attempt is made to delete a client contact that is still
9+
* referenced by another system (e.g. EMS, GAS2, LEXIS, or SCS).
10+
*/
11+
@ResponseStatus(HttpStatus.CONFLICT)
12+
public class ContactInUseException extends ResponseStatusException {
13+
14+
/**
15+
* Creates the exception with an HTTP 409 (Conflict) status and a message explaining that the
16+
* contact must be removed from the other system before it can be deleted here.
17+
*/
18+
public ContactInUseException() {
19+
super(
20+
HttpStatus.CONFLICT,
21+
"You can't delete this contact yet because it's being used by EMS, GAS2, LEXIS, or SCS. "
22+
+ "Remove it from the other system first, then try again."
23+
);
24+
}
25+
26+
}

legacy/src/main/java/ca/bc/gov/app/repository/ForestClientQueries.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import lombok.AccessLevel;
44
import lombok.NoArgsConstructor;
55

6+
/**
7+
* Holds the native SQL queries used by the forest client repositories and services.
8+
*/
69
@NoArgsConstructor(access = AccessLevel.PRIVATE)
710
public final class ForestClientQueries {
811

@@ -1384,4 +1387,66 @@ OR UPPER(CL.CLI_LOCN_COMMENT) LIKE '%' || UPPER(:comment) || '%'
13841387
+ ADVANCED_SEARCH_BASE_FROM
13851388
+ ADVANCED_SEARCH_WHERE;
13861389

1390+
/**
1391+
* Deletes all contacts of a client that share the same CONTACT_NAME as the provided contact id.
1392+
*/
1393+
public static final String REMOVE_ALL_CONTACTS = """
1394+
DELETE FROM THE.CLIENT_CONTACT
1395+
WHERE CLIENT_NUMBER = :client_number
1396+
AND CONTACT_NAME = (
1397+
SELECT cl.CONTACT_NAME FROM THE.CLIENT_CONTACT cl WHERE cl.CLIENT_CONTACT_ID = :entity_id
1398+
)""";
1399+
1400+
/**
1401+
* Locks (with {@code FOR UPDATE}) all contacts that would be removed (same client and
1402+
* CONTACT_NAME as the provided contact id). Must run inside the same transaction as
1403+
* {@link #COUNT_CONTACTS_IN_USE} and {@link #REMOVE_ALL_CONTACTS}: because
1404+
* {@code THE.SCALE_SITE_CONTACT} has a foreign key to {@code THE.CLIENT_CONTACT}, holding this
1405+
* lock blocks a concurrent {@code SCALE_SITE_CONTACT} insert referencing one of these contacts
1406+
* until the transaction commits or rolls back, closing the gap between the in-use check and
1407+
* the delete.
1408+
*/
1409+
public static final String LOCK_CONTACTS_FOR_UPDATE = """
1410+
SELECT cc.CLIENT_CONTACT_ID
1411+
FROM THE.CLIENT_CONTACT cc
1412+
WHERE cc.CLIENT_NUMBER = :client_number
1413+
AND cc.CONTACT_NAME = (
1414+
SELECT cl.CONTACT_NAME
1415+
FROM THE.CLIENT_CONTACT cl
1416+
WHERE cl.CLIENT_CONTACT_ID = :entity_id
1417+
)
1418+
FOR UPDATE""";
1419+
1420+
/**
1421+
* Counts how many of the contacts that would be removed (same client and CONTACT_NAME as the
1422+
* provided contact id) are referenced by another system through THE.SCALE_SITE_CONTACT.
1423+
*/
1424+
public static final String COUNT_CONTACTS_IN_USE = """
1425+
SELECT COUNT(1) AS IN_USE_COUNT
1426+
FROM THE.SCALE_SITE_CONTACT ssc
1427+
WHERE ssc.CLIENT_CONTACT_ID IN (
1428+
SELECT cc.CLIENT_CONTACT_ID
1429+
FROM THE.CLIENT_CONTACT cc
1430+
WHERE cc.CLIENT_NUMBER = :client_number
1431+
AND cc.CONTACT_NAME = (
1432+
SELECT cl.CONTACT_NAME
1433+
FROM THE.CLIENT_CONTACT cl
1434+
WHERE cl.CLIENT_CONTACT_ID = :entity_id
1435+
)
1436+
)""";
1437+
1438+
/**
1439+
* Retrieves all contact ids of a client that share the same CONTACT_NAME as the provided
1440+
* contact id.
1441+
*/
1442+
public static final String GET_ALL_CONTACT_IDS = """
1443+
SELECT CLIENT_CONTACT_ID FROM THE.CLIENT_CONTACT
1444+
WHERE
1445+
CLIENT_NUMBER = :client_number
1446+
AND CONTACT_NAME = (
1447+
SELECT cl.CONTACT_NAME
1448+
FROM THE.CLIENT_CONTACT cl
1449+
WHERE cl.CLIENT_CONTACT_ID = :entity_id
1450+
)""";
1451+
13871452
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package ca.bc.gov.app.repository;
2+
3+
import ca.bc.gov.app.entity.ScaleSiteContactEntity;
4+
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
5+
import org.springframework.stereotype.Repository;
6+
import reactor.core.publisher.Mono;
7+
8+
/**
9+
* Repository for the {@link ScaleSiteContactEntity}.
10+
*
11+
* <p>Provides methods to query the SCALE_SITE_CONTACT table, which is used by other
12+
* systems (EMS, GAS2, LEXIS, SCS) to reference client contacts.</p>
13+
*/
14+
@Repository
15+
public interface ScaleSiteContactRepository
16+
extends ReactiveCrudRepository<ScaleSiteContactEntity, Long> {
17+
18+
/**
19+
* Checks whether a record exists in SCALE_SITE_CONTACT for the given client contact id.
20+
*
21+
* @param clientContactId the client contact id to check
22+
* @return a {@link Mono} emitting {@code true} if a matching record exists, {@code false}
23+
* otherwise
24+
*/
25+
Mono<Boolean> existsByClientContactId(Long clientContactId);
26+
27+
}

0 commit comments

Comments
 (0)