feat(clients): maker-checker for client creation and edits (backend)

- Client activation now requires a different user than the one who created the client
  (client.getCreatedBy() vs current user) — no bypass, including CHECKER_SUPER_USER, since a
  bypass would recreate the exact "role bypasses the hold" loophole this closes. Client creation
  itself is unaffected (the wizard needs a real client id immediately for photo/KYC/address/family
  follow-up uploads), only activation is gated.
- New maker-checker queue for Client edits, modelled on this codebase's existing
  m_share_account_pending_edit / chequebook patterns rather than Fineract's native
  command-approval engine (which any ALL_FUNCTIONS role bypasses unconditionally):
  - m_client_pending_edit (migrations 0429-0431): stores the maker's raw edit payload plus a
    point-in-time "before" snapshot (original_json, extracted from the frontend's _originalValues
    key) so the checker's diff stays accurate even after the edit is approved/rejected, when the
    live client no longer reflects the pre-edit state.
  - ClientEditWritePlatformService: submitEditForApproval() stages the edit (refusing a second
    submission while one's already pending, via a nullable-but-unique pending_lock_key);
    approveEdit() replays the payload through the existing updateClient() so all of Client's
    validation/change-detection runs unchanged, just deferred; rejectEdit() discards it. The maker
    can never approve/reject their own submission.
  - ClientEditApiResource: /clients/{id}/edit (submit), /clients/edit/pending (paginated +
    status/clientName/date-range filterable list, mirrors /chequebook/pending-requests),
    /clients/{id}/edit/approve, /clients/{id}/edit/reject. New permissions (migration 0430):
    SUBMITEDIT_CLIENT, APPROVEEDIT_CLIENT, REJECTEDIT_CLIENT, APPROVEEDIT_CLIENT_CHECKER (the
    last one surfaces pending edits in the generic Checker Inbox too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 10:31:10 +05:30
co-authored by Claude Sonnet 5
parent 3c58bb231f
commit a65d801250
19 changed files with 1091 additions and 0 deletions
@@ -0,0 +1,164 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriInfo;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clients")
@Component
@Tag(name = "Client Edit Approval",
description = "Maker-checker workflow for editing an existing Client's core profile details. Edits are held in "
+ "m_client_pending_edit (a pending row there is itself the lock) and only applied to the real client once a "
+ "different user (the checker) approves — see /clients/{clientId}/edit/approve and /reject below.")
@RequiredArgsConstructor
public class ClientEditApiResource {
private final PlatformSecurityContext context;
private final ClientEditWritePlatformService clientEditWritePlatformService;
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final ApiRequestParameterHelper apiRequestParameterHelper;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<ClientPendingEditData> pendingEditApiJsonSerializer;
@POST
@Path("{clientId}/edit")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a details edit for a Client",
description = "Stages the proposed changes in m_client_pending_edit instead of applying them immediately — see "
+ "/clients/{clientId}/edit/approve and /reject below.")
public String submitEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
this.context.authenticatedUser().validateHasPermissionTo("SUBMITEDIT_CLIENT");
final JsonCommand command = JsonCommand.fromJsonElement(clientId, this.fromApiJsonHelper.parse(apiRequestBodyAsJson),
this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientEditWritePlatformService.submitEditForApproval(clientId, command);
return this.toApiJsonSerializer.serialize(result);
}
@GET
@Path("edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List Client details edits pending approval",
description = "Supports status/clientName/fromDate/toDate filtering (fromDate/toDate as yyyy-MM-dd, inclusive, filtered "
+ "on submitted date; clientName is a case-insensitive substring match) plus offset/limit pagination — mirrors "
+ "/chequebook/pending-requests.")
public Page<ClientPendingEditData> retrieveAllPendingEdits(@QueryParam("status") final String status,
@QueryParam("clientName") final String clientName, @QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate, @QueryParam("offset") @DefaultValue("0") final Integer offset,
@QueryParam("limit") @DefaultValue("20") final Integer limit) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final int safeLimit = Math.min(limit, 200);
return this.clientPendingEditReadPlatformService.retrieveAllPendingEdits(status, clientName, fromDate, toDate, offset, safeLimit);
}
@GET
@Path("{clientId}/edit/pending")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the pending details edit for a Client, if any")
public String retrievePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Context final UriInfo uriInfo) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final ClientPendingEditData pendingEdit = this.clientPendingEditReadPlatformService.retrievePendingEdit(clientId);
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
return this.pendingEditApiJsonSerializer.serialize(settings, pendingEdit);
}
@POST
@Path("{clientId}/edit/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Client details edit",
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to approve client edits");
}
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
return this.toApiJsonSerializer.serialize(result);
}
@POST
@Path("{clientId}/edit/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a pending Client details edit",
description = "Discards the proposed changes without applying them. The maker of the edit cannot also reject it — no exceptions.")
public String rejectPendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("REJECTEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to reject client edits");
}
String rejectReason = null;
if (apiRequestBodyAsJson != null && !apiRequestBodyAsJson.isBlank()) {
final var parsed = this.fromApiJsonHelper.parse(apiRequestBodyAsJson);
if (parsed != null && parsed.isJsonObject() && parsed.getAsJsonObject().has("rejectReason")) {
rejectReason = parsed.getAsJsonObject().get("rejectReason").getAsString();
}
}
final CommandProcessingResult result = this.clientEditWritePlatformService.rejectEdit(clientId, rejectReason);
return this.toApiJsonSerializer.serialize(result);
}
}
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class ClientPendingEditData {
private final Long id;
private final Long clientId;
private final String clientDisplayName;
private final String payloadJson;
private final String originalJson;
private final String status;
private final Long makerId;
private final String makerName;
private final OffsetDateTime submittedDate;
private final Long checkerId;
private final String checkerName;
private final OffsetDateTime checkedDate;
private final String rejectReason;
}
@@ -0,0 +1,132 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Bespoke maker-checker for Client edits — modelled on {@link ClientPendingEditStatus} /
* {@code m_share_account_pending_edit} elsewhere in this codebase rather than Fineract's native
* command-approval engine, because that engine's hold is bypassed by any role carrying
* ALL_FUNCTIONS (Super user, Clerk in this tenant), making it a no-op for this CBS's actual admin
* accounts.
*
* <p>Holds the maker's raw PUT /clients/{id}/edit payload as-is (rather than one typed column per
* editable Client field, which — given how many fields Client actually has across general,
* financial-classification and KYC-adjacent data — would mean a huge, fragile shadow-column table
* that has to be kept in lockstep with the entity forever). Approval reconstructs a
* {@code JsonCommand} from the stored JSON and replays it through the existing
* {@code ClientWritePlatformService.updateClient(...)}, so all of Client's existing validation and
* change-detection logic runs exactly as it would for a direct edit — just deferred until a
* different user approves it. The maker can never approve/reject their own submission — enforced
* in the write service, not just by role, with no bypass for CHECKER_SUPER_USER.
*/
@Entity
@Table(name = "m_client_pending_edit")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "payload_json", nullable = false)
private String payloadJson;
/** Point-in-time "before" snapshot the maker's client had when the edit was submitted — see
* client-edit-diff.util.ts's buildOriginalValuesSnapshot(). Null for edits submitted before
* this column existed. Lets the checker's compare view show a true historical diff regardless
* of whether the live client has since changed (e.g. once approved, current == proposed). */
@Column(name = "original_json")
private String originalJson;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "maker_id", nullable = false)
private Long makerId;
@Column(name = "submitted_date", nullable = false)
private OffsetDateTime submittedDate;
@Column(name = "checker_id")
private Long checkerId;
@Column(name = "checked_date")
private OffsetDateTime checkedDate;
@Column(name = "reject_reason", length = 500)
private String rejectReason;
@Column(name = "pending_lock_key", length = 60, unique = true)
private String pendingLockKey;
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
final AppUser maker) {
final ClientPendingEdit edit = new ClientPendingEdit();
edit.client = client;
edit.payloadJson = payloadJson;
edit.originalJson = originalJson;
edit.status = ClientPendingEditStatus.PENDING.name();
edit.makerId = maker.getId();
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
edit.pendingLockKey = lockKeyFor(client.getId());
return edit;
}
public static String lockKeyFor(final Long clientId) {
return "CLIENT_EDIT:" + clientId;
}
public boolean isPending() {
return ClientPendingEditStatus.PENDING.name().equals(this.status);
}
/** The maker can never be their own checker — a distinct human must approve/reject an edit. */
public boolean isMadeBy(final Long userId) {
return this.makerId != null && this.makerId.equals(userId);
}
public void approve(final AppUser checker) {
this.status = ClientPendingEditStatus.APPROVED.name();
this.checkerId = checker.getId();
this.checkedDate = DateUtils.getOffsetDateTimeOfTenant();
this.pendingLockKey = null;
}
public void reject(final AppUser checker, final String reason) {
this.status = ClientPendingEditStatus.REJECTED.name();
this.checkerId = checker.getId();
this.checkedDate = DateUtils.getOffsetDateTimeOfTenant();
this.rejectReason = reason;
this.pendingLockKey = null;
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClientPendingEditRepository extends JpaRepository<ClientPendingEdit, Long> {
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
}
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class ClientPendingEditRepositoryWrapper {
private final ClientPendingEditRepository repository;
public ClientPendingEdit findPendingByClientIdWithNotFoundDetection(final Long clientId) {
return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name())
.orElseThrow(() -> new ClientNoPendingEditException(clientId));
}
public Optional<ClientPendingEdit> findPendingByClientId(final Long clientId) {
return this.repository.findByClientIdAndStatus(clientId, ClientPendingEditStatus.PENDING.name());
}
public List<ClientPendingEdit> findAllPending() {
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
}
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
return this.repository.saveAndFlush(edit);
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/** Lifecycle of a {@link ClientPendingEdit}. Terminal once APPROVED or REJECTED. */
public enum ClientPendingEditStatus {
PENDING, //
APPROVED, //
REJECTED; //
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the user attempting to activate a client is the same user who created it — a
* four-eyes (maker-checker) requirement with no bypass, including for users holding
* CHECKER_SUPER_USER, since a bypass would recreate the exact "ALL_FUNCTIONS/super user ignores
* the hold" loophole this check exists to close.
*/
public class ClientActivationSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public ClientActivationSelfApprovalNotAllowedException() {
super("error.msg.client.activation.self.approval.not.allowed",
"You cannot activate a client you created yourself — a different user must activate it.");
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ClientEditPendingApprovalException extends AbstractPlatformDomainRuleException {
public ClientEditPendingApprovalException(final Long clientId) {
super("error.msg.client.edit.pending.approval", "Client with identifier " + clientId + " already has an edit pending approval",
clientId);
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
/**
* Thrown when the user attempting to approve/reject a Client's pending edit is the same user who
* submitted it — no bypass, including for users holding CHECKER_SUPER_USER, since a bypass would
* recreate the exact "role bypasses the hold" loophole this check exists to close.
*/
public class ClientEditSelfApprovalNotAllowedException extends AbstractPlatformDomainRuleException {
public ClientEditSelfApprovalNotAllowedException(final Long clientId) {
super("error.msg.client.edit.self.approval.not.allowed",
"You cannot approve or reject an edit you submitted yourself for client " + clientId
+ " — a different user must review it.",
clientId);
}
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class ClientNoPendingEditException extends AbstractPlatformDomainRuleException {
public ClientNoPendingEditException(final Long clientId) {
super("error.msg.client.no.pending.edit", "Client with identifier " + clientId + " has no edit pending approval", clientId);
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/** Maker-checker for Client edits — see {@link org.apache.fineract.portfolio.client.domain.ClientPendingEdit}. */
public interface ClientEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
CommandProcessingResult approveEdit(Long clientId);
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
}
@@ -0,0 +1,136 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Service
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final ClientWritePlatformService clientWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
@Transactional
@Override
public CommandProcessingResult submitEditForApproval(final Long clientId, final JsonCommand command) {
final AppUser maker = this.context.authenticatedUser();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
// Four-eyes lock: refuse a second maker while one edit is already awaiting approval for
// this client, rather than silently overwriting or queueing it — matches
// m_share_account_pending_edit / m_savings_product_temp elsewhere in this codebase.
if (this.pendingEditRepository.findPendingByClientId(clientId).isPresent()) {
throw new ClientEditPendingApprovalException(clientId);
}
// The frontend rides its point-in-time "before" snapshot along under _originalValues so
// the checker's compare view can show a true historical diff later, even after the edit
// has been applied (by then the live client equals the proposed values, not the original
// ones — see client-edit-diff.util.ts). Split it out here: the rest replays through
// updateClient() at approval time and must not carry an unrecognised parameter.
final JsonElement parsedPayload = this.fromApiJsonHelper.parse(command.json());
String originalJson = null;
String cleanPayloadJson = command.json();
if (parsedPayload != null && parsedPayload.isJsonObject()) {
final JsonObject payloadObject = parsedPayload.getAsJsonObject();
final JsonElement originalValues = payloadObject.remove(ORIGINAL_VALUES_PAYLOAD_KEY);
if (originalValues != null) {
originalJson = originalValues.toString();
}
cleanPayloadJson = payloadObject.toString();
}
final ClientPendingEdit pendingEdit = ClientPendingEdit.submit(client, cleanPayloadJson, originalJson, maker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withOfficeId(client.officeId()) //
.withClientId(clientId) //
.withEntityId(pendingEdit.getId()) //
.build();
}
@Transactional
@Override
public CommandProcessingResult approveEdit(final Long clientId) {
final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
// No bypass — including CHECKER_SUPER_USER — a bypass here would recreate the exact
// "role bypasses the hold" loophole this whole feature exists to close.
if (pendingEdit.isMadeBy(checker.getId())) {
throw new ClientEditSelfApprovalNotAllowedException(clientId);
}
// Replay the staged JSON through the SAME write-service method a direct PUT would have
// used, so every bit of Client's existing validation/change-detection logic runs exactly
// as it would for an immediate edit — just deferred until a different user approves it.
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
pendingEdit.approve(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit);
return result;
}
@Transactional
@Override
public CommandProcessingResult rejectEdit(final Long clientId, final String rejectReason) {
final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
if (pendingEdit.isMadeBy(checker.getId())) {
throw new ClientEditSelfApprovalNotAllowedException(clientId);
}
pendingEdit.reject(checker, rejectReason);
this.pendingEditRepository.saveAndFlush(pendingEdit);
return new CommandProcessingResultBuilder() //
.withClientId(clientId) //
.withEntityId(pendingEdit.getId()) //
.build();
}
}
@@ -0,0 +1,36 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
public interface ClientPendingEditReadPlatformService {
ClientPendingEditData retrievePendingEdit(Long clientId);
/**
* @param status defaults to PENDING when blank
* @param clientName case-insensitive substring match against the client's display name
* @param fromDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date
* @param toDate ISO (yyyy-MM-dd), inclusive, filters on submitted_date
*/
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
Integer limit);
}
@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.exception.ClientNoPendingEditException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEditReadPlatformService {
private final JdbcTemplate jdbcTemplate;
private static final String SELECT_SQL = """
SELECT pe.id AS id, pe.client_id AS clientId, c.display_name AS clientDisplayName,
pe.payload_json AS payloadJson, pe.original_json AS originalJson, pe.status AS status,
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
pe.submitted_date AS submittedDate,
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id
JOIN m_appuser maker ON maker.id = pe.maker_id
LEFT JOIN m_appuser checker ON checker.id = pe.checker_id
""";
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
return ClientPendingEditData.builder() //
.id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) //
.clientDisplayName(rs.getString("clientDisplayName")) //
.payloadJson(rs.getString("payloadJson")) //
.originalJson(rs.getString("originalJson")) //
.status(rs.getString("status")) //
.makerId(rs.getLong("makerId")) //
.makerName(rs.getString("makerName")) //
.submittedDate(submittedDate) //
.checkerId(JdbcSupport.getLong(rs, "checkerId")) //
.checkerName(rs.getString("checkerName")) //
.checkedDate(checkedDate) //
.rejectReason(rs.getString("rejectReason")) //
.build();
};
@Override
public ClientPendingEditData retrievePendingEdit(final Long clientId) {
try {
return this.jdbcTemplate.queryForObject(SELECT_SQL + " WHERE pe.client_id = ? AND pe.status = 'PENDING'", MAPPER, clientId);
} catch (final EmptyResultDataAccessException e) {
throw new ClientNoPendingEditException(clientId);
}
}
@Override
public Page<ClientPendingEditData> retrieveAllPendingEdits(final String status, final String clientName, final String fromDate,
final String toDate, final Integer offset, final Integer limit) {
final StringBuilder where = new StringBuilder(" WHERE 1=1");
final List<Object> args = new ArrayList<>();
if (StringUtils.isNotBlank(status)) {
where.append(" AND pe.status = ?");
args.add(status.trim().toUpperCase());
} else {
where.append(" AND pe.status = 'PENDING'");
}
if (StringUtils.isNotBlank(clientName)) {
where.append(" AND c.display_name ILIKE ?");
args.add("%" + clientName.trim() + "%");
}
if (StringUtils.isNotBlank(fromDate)) {
where.append(" AND pe.submitted_date::date >= ?::date");
args.add(fromDate);
}
if (StringUtils.isNotBlank(toDate)) {
where.append(" AND pe.submitted_date::date <= ?::date");
args.add(toDate);
}
final String countSql = "SELECT COUNT(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id" + where;
final Long totalFilteredRecords = this.jdbcTemplate.queryForObject(countSql, Long.class, args.toArray());
final int pageOffset = offset == null ? 0 : offset;
final int pageLimit = limit == null ? 10 : limit;
final String pageSql = SELECT_SQL + where + " ORDER BY pe.submitted_date DESC LIMIT ? OFFSET ?";
final List<Object> pageArgs = new ArrayList<>(args);
pageArgs.add(pageLimit);
pageArgs.add(pageOffset);
final List<ClientPendingEditData> items = this.jdbcTemplate.query(pageSql, MAPPER, pageArgs.toArray());
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
}
}
@@ -74,6 +74,7 @@ import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientStatus;
import org.apache.fineract.portfolio.client.domain.LegalForm;
import org.apache.fineract.portfolio.client.exception.ClientActivationSelfApprovalNotAllowedException;
import org.apache.fineract.portfolio.client.exception.ClientActiveForUpdateException;
import org.apache.fineract.portfolio.client.exception.ClientHasNoStaffException;
import org.apache.fineract.portfolio.client.exception.ClientMustBePendingToBeDeletedException;
@@ -1457,6 +1458,12 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
runEntityDatatableCheck(clientId, client.getLegalForm());
final AppUser currentUser = this.context.authenticatedUser();
// Four-eyes principle: whoever created this client (the maker) cannot also be the one
// who activates it (the checker) — no bypass, including for CHECKER_SUPER_USER, since
// that would recreate the exact "role bypasses the hold" loophole this closes.
if (client.getCreatedBy().isPresent() && client.getCreatedBy().get().equals(currentUser.getId())) {
throw new ClientActivationSelfApprovalNotAllowedException();
}
client.activate(currentUser, fmt, activationDate);
CommandProcessingResult result = openSavingsAccount(client, fmt);
clientRepository.saveAndFlush(client);
@@ -449,6 +449,9 @@
<include file="parts/0426_add_graduate_education_level.xml" relativeToChangelogFile="true"/>
<include file="parts/0427_add_caretaker_relationship_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0428_add_minor_kyc_id_proof_codes.xml" relativeToChangelogFile="true"/>
<include file="parts/0429_add_client_pending_edit_table.xml" relativeToChangelogFile="true"/>
<include file="parts/0430_add_client_edit_approval_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0431_add_original_json_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<!-- NOTE: the numbers below (0341-0359) re-use prefixes already used above by the chequebook/CTS
clearing catchup migrations — these come from a long-diverged branch (uate) that assigned its own
sequence independently. Liquibase tracks changesets by filename + id + author, not by the numeric
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Maker-checker for Client edits: the maker's PUT /clients/{id}/edit payload is staged here
(raw JSON, replayed through the existing ClientWritePlatformService.updateClient() on
approval, so all existing validation/business logic is reused unchanged) rather than
applied immediately. `pending_lock_key` is a nullable-but-unique column (NULL while not
pending) used to prevent a second maker from submitting another edit while one is already
awaiting approval for the same client — see m_share_account_pending_edit /
m_savings_product_temp for the same pattern elsewhere in this codebase. Resolved rows are
kept (status APPROVED/REJECTED) for audit history rather than deleted, which is exactly
why the lock can't be a plain UNIQUE(client_id) — a client could never be edited a second
time after its first edit was ever resolved. -->
<changeSet author="CBS" id="0429-1-create-client-pending-edit-table">
<createTable tableName="m_client_pending_edit">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_client_pending_edit_client" references="m_client(id)"/>
</column>
<column name="payload_json" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_client_pending_edit_maker" references="m_appuser(id)"/>
</column>
<column name="submitted_date" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="checker_id" type="BIGINT">
<constraints nullable="true" foreignKeyName="fk_client_pending_edit_checker" references="m_appuser(id)"/>
</column>
<column name="checked_date" type="TIMESTAMP WITH TIME ZONE"/>
<column name="reject_reason" type="VARCHAR(500)"/>
<column name="pending_lock_key" type="VARCHAR(60)">
<constraints nullable="true" unique="true" uniqueConstraintName="uq_client_pending_edit_lock_key"/>
</column>
</createTable>
</changeSet>
<changeSet author="CBS" id="0429-2-index-client-pending-edit-client-id">
<createIndex indexName="idx_client_pending_edit_client_id" tableName="m_client_pending_edit">
<column name="client_id"/>
</createIndex>
</changeSet>
<changeSet author="CBS" id="0429-3-index-client-pending-edit-status">
<createIndex indexName="idx_client_pending_edit_status" tableName="m_client_pending_edit">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Permissions for the Client edit maker-checker workflow (ClientEditApiResource /
m_client_pending_edit). can_maker_checker=false on all four — these rows ARE the bespoke
gate itself, so layering Fineract's native command-hold on top would be redundant.
APPROVEEDIT_CLIENT_CHECKER (action_name ending "_CHECKER") makes pending Client edits also
show up in the generic Checker Inbox (GET /v1/makercheckers) for limited checkers whose
role holds it, mirroring 2022_add_product_checker_permissions.xml elsewhere in this repo. -->
<changeSet author="CBS" id="0430-1-add-client-edit-approval-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'SUBMITEDIT_CLIENT'</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="SUBMITEDIT_CLIENT"/>
<column name="entity_name" value="CLIENT"/>
<column name="action_name" value="SUBMITEDIT"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="APPROVEEDIT_CLIENT"/>
<column name="entity_name" value="CLIENT"/>
<column name="action_name" value="APPROVEEDIT"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="REJECTEDIT_CLIENT"/>
<column name="entity_name" value="CLIENT"/>
<column name="action_name" value="REJECTEDIT"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="APPROVEEDIT_CLIENT_CHECKER"/>
<column name="entity_name" value="CLIENT"/>
<column name="action_name" value="APPROVEEDIT_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
<!-- Point-in-time "before" snapshot of the client's own values, captured by the frontend from
the record it loaded when the edit page was opened (see buildOriginalValuesSnapshot() in
client-edit-diff.util.ts) and submitted alongside the edit under the _originalValues key,
which ClientEditWritePlatformServiceImpl strips out before storing/replaying the rest as
payload_json. Needed because diffing the staged payload against the LIVE client (as the
checker UI originally did) shows nothing once an edit is approved — by then the live
client already equals the proposed values. Nullable: existing pending-edit rows created
before this column existed simply have no "before" snapshot to diff against. -->
<changeSet author="CBS" id="0431-1-add-original-json-to-client-pending-edit">
<addColumn tableName="m_client_pending_edit">
<column name="original_json" type="TEXT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>