Case Punching Feature Done

This commit is contained in:
2026-08-02 00:47:00 +05:30
parent 452e6189e4
commit af360d7793
67 changed files with 1431 additions and 179 deletions

2
.vscode/launch.json vendored
View File

@@ -68,6 +68,8 @@
"CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt", "CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01", "CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem", "CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem",
"CYGNUS_PAYLOAD_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/case-save-private.pem",
"CYGNUS_PAYLOAD_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/case-save-public.pem",
"CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT10S", "CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT10S",
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S" "CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
}, },

View File

@@ -62,4 +62,15 @@ public class CloudIdentityClient {
.bodyToMono(CloudQueryResponse.class)) .bodyToMono(CloudQueryResponse.class))
.timeout(properties.requestTimeout()); .timeout(properties.requestTimeout());
} }
public Mono<CloudQueryResponse> fetchQuery(String queryKey) {
return tokenProvider.accessToken()
.flatMap(token -> webClient.get()
.uri(properties.baseUri().resolve("/api/v1/queries/key/" + queryKey))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(CloudQueryResponse.class))
.timeout(properties.requestTimeout());
}
} }

View File

@@ -25,4 +25,10 @@ public class CloudQueryController {
return repository.findEnabled(queryId) return repository.findEnabled(queryId)
.switchIfEmpty(Mono.error(new QueryNotFoundException(queryId))); .switchIfEmpty(Mono.error(new QueryNotFoundException(queryId)));
} }
@GetMapping("/key/{queryKey}")
public Mono<CloudQuery> queryByKey(@PathVariable String queryKey) {
return repository.findEnabled(queryKey)
.switchIfEmpty(Mono.error(new QueryNotFoundException(queryKey)));
}
} }

View File

@@ -10,8 +10,10 @@ public class QueryCatalogRepository {
private static final String INITIALIZE = """ private static final String INITIALIZE = """
CREATE SCHEMA IF NOT EXISTS platform; CREATE SCHEMA IF NOT EXISTS platform;
CREATE SEQUENCE IF NOT EXISTS platform.application_query_id_seq;
CREATE TABLE IF NOT EXISTS platform.application_query ( CREATE TABLE IF NOT EXISTS platform.application_query (
query_id integer PRIMARY KEY, query_id integer PRIMARY KEY DEFAULT nextval('platform.application_query_id_seq'),
query_key varchar(100),
query_text text NOT NULL, query_text text NOT NULL,
enabled boolean NOT NULL DEFAULT true, enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT current_timestamp, created_at timestamptz NOT NULL DEFAULT current_timestamp,
@@ -20,7 +22,15 @@ public class QueryCatalogRepository {
CHECK (query_id > 0), CHECK (query_id > 0),
CONSTRAINT ck_platform_application_query_text CONSTRAINT ck_platform_application_query_text
CHECK (length(btrim(query_text)) > 0) CHECK (length(btrim(query_text)) > 0)
) );
ALTER TABLE platform.application_query
ADD COLUMN IF NOT EXISTS query_key varchar(100);
ALTER TABLE platform.application_query ALTER COLUMN query_id
SET DEFAULT nextval('platform.application_query_id_seq');
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_application_query_key
ON platform.application_query (query_key) WHERE query_key IS NOT NULL;
SELECT setval('platform.application_query_id_seq',
greatest(coalesce((SELECT max(query_id) FROM platform.application_query), 0) + 1, 1), false)
"""; """;
private static final String FIND = """ private static final String FIND = """
SELECT query_id, query_text SELECT query_id, query_text
@@ -28,6 +38,12 @@ public class QueryCatalogRepository {
WHERE query_id = $1 WHERE query_id = $1
AND enabled = true AND enabled = true
"""; """;
private static final String FIND_BY_KEY = """
SELECT query_id, query_text
FROM platform.application_query
WHERE query_key = $1
AND enabled = true
""";
private final ReactiveDatabaseClient database; private final ReactiveDatabaseClient database;
public QueryCatalogRepository(ReactiveDatabaseClient database) { public QueryCatalogRepository(ReactiveDatabaseClient database) {
@@ -45,4 +61,11 @@ public class QueryCatalogRepository {
.map(row -> new CloudQuery( .map(row -> new CloudQuery(
row.getInteger("query_id"), row.getString("query_text"))); row.getInteger("query_id"), row.getString("query_text")));
} }
public Mono<CloudQuery> findEnabled(String queryKey) {
return database.preparedQuery(FIND_BY_KEY, Tuple.of(queryKey))
.flatMapMany(rows -> reactor.core.publisher.Flux.fromIterable(rows))
.next()
.map(row -> new CloudQuery(row.getInteger("query_id"), row.getString("query_text")));
}
} }

View File

@@ -9,4 +9,8 @@ public class QueryNotFoundException extends RuntimeException {
public QueryNotFoundException(int queryId) { public QueryNotFoundException(int queryId) {
super("Query was not found: " + queryId); super("Query was not found: " + queryId);
} }
public QueryNotFoundException(String queryKey) {
super("Query was not found: " + queryKey);
}
} }

View File

@@ -1,7 +1,9 @@
CREATE SCHEMA IF NOT EXISTS platform; CREATE SCHEMA IF NOT EXISTS platform;
CREATE SEQUENCE IF NOT EXISTS platform.application_query_id_seq;
CREATE TABLE IF NOT EXISTS platform.application_query ( CREATE TABLE IF NOT EXISTS platform.application_query (
query_id integer PRIMARY KEY, query_id integer PRIMARY KEY DEFAULT nextval('platform.application_query_id_seq'),
query_key varchar(100),
query_text text NOT NULL, query_text text NOT NULL,
enabled boolean NOT NULL DEFAULT true, enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT current_timestamp, created_at timestamptz NOT NULL DEFAULT current_timestamp,
@@ -11,3 +13,11 @@ CREATE TABLE IF NOT EXISTS platform.application_query (
CONSTRAINT ck_platform_application_query_text CONSTRAINT ck_platform_application_query_text
CHECK (length(btrim(query_text)) > 0) CHECK (length(btrim(query_text)) > 0)
); );
ALTER TABLE platform.application_query
ADD COLUMN IF NOT EXISTS query_key varchar(100);
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_application_query_key
ON platform.application_query (query_key) WHERE query_key IS NOT NULL;
ALTER TABLE platform.application_query ALTER COLUMN query_id
SET DEFAULT nextval('platform.application_query_id_seq');
SELECT setval('platform.application_query_id_seq',
greatest(coalesce((SELECT max(query_id) FROM platform.application_query), 0) + 1, 1), false);

View File

@@ -0,0 +1,11 @@
-- Stable query key; query_id is generated by PostgreSQL.
BEGIN;
INSERT INTO platform.application_query(query_key, query_text, enabled, created_at, updated_at, ismigrated)
VALUES ('APPLICATION_SAVE',
'procedure!C0L!select * from public.save_application_details(?::jsonb,?::smallint,?::smallint,?::smallint)',
true, clock_timestamp(), clock_timestamp(), true)
ON CONFLICT (query_key) WHERE query_key IS NOT NULL DO UPDATE SET
query_text=excluded.query_text, enabled=true, updated_at=clock_timestamp(), ismigrated=true;
COMMIT;

View File

@@ -1,9 +1,7 @@
package lib.constants; package lib.constants;
import java.util.Arrays; /** Canonical user-facing messages shared by Cygnus applications. */
public enum ApplicationMessage {
/** Canonical user-facing errors shared by Cygnus applications. */
public enum ApplicationError {
BAD_REQUEST(400, "HTTP-400", "Invalid request", BAD_REQUEST(400, "HTTP-400", "Invalid request",
"Cygnus could not process the submitted request.", "Cygnus could not process the submitted request.",
"Review the supplied information and try again."), "Review the supplied information and try again."),
@@ -28,6 +26,23 @@ public enum ApplicationError {
TOO_MANY_REQUESTS(429, "HTTP-429", "Too many requests", TOO_MANY_REQUESTS(429, "HTTP-429", "Too many requests",
"Cygnus has received too many requests in a short period.", "Cygnus has received too many requests in a short period.",
"Wait briefly before trying again."), "Wait briefly before trying again."),
PAYLOAD_INVALID(400, "REQ-4001", "Invalid request details",
"The encrypted request could not be verified.",
"Refresh the page and submit the form again."),
APPLICATION_VALIDATION_FAILED(422, "APP-4221", "Application validation failed",
"One or more application fields contain invalid information.",
"Correct the highlighted fields and submit the application again."),
APPLICATION_ACCESS_DENIED(403, "APP-4031", "Application access denied",
"The selected portfolio or application is not available to your account.",
"Select an authorized portfolio or contact your administrator."),
APPLICATION_SAVE_CONFLICT(409, "APP-4091", "Application save conflict",
"This request was already processed or the application changed.",
"Refresh the application before submitting it again."),
APPLICATION_SAVED(200, "APP-2001", "Application saved",
"The application details were saved successfully.", ""),
APPLICATION_SAVE_FAILED(500, "APP-5001", "Application could not be saved",
"Cygnus could not save the application details.",
"Try again. If the issue continues, share the reference ID with support."),
INTERNAL_SERVER_ERROR(500, "HTTP-500", "Something went wrong", INTERNAL_SERVER_ERROR(500, "HTTP-500", "Something went wrong",
"Cygnus could not complete your request.", "Cygnus could not complete your request.",
"Try again. If the issue continues, share the reference ID with support."), "Try again. If the issue continues, share the reference ID with support."),
@@ -47,7 +62,7 @@ public enum ApplicationError {
private final String message; private final String message;
private final String description; private final String description;
ApplicationError( ApplicationMessage(
int httpStatus, String code, String title, String message, String description) { int httpStatus, String code, String title, String message, String description) {
this.httpStatus = httpStatus; this.httpStatus = httpStatus;
this.code = code; this.code = code;
@@ -56,11 +71,21 @@ public enum ApplicationError {
this.description = description; this.description = description;
} }
public static ApplicationError fromHttpStatus(int status) { public static ApplicationMessage fromHttpStatus(int status) {
return Arrays.stream(values()) return switch (status) {
.filter(error -> error.httpStatus == status) case 400 -> BAD_REQUEST;
.findFirst() case 401 -> SESSION_REQUIRED;
.orElse(INTERNAL_SERVER_ERROR); case 403 -> ACCESS_DENIED;
case 404 -> PAGE_NOT_FOUND;
case 405 -> METHOD_NOT_ALLOWED;
case 409 -> CONFLICT;
case 413 -> PAYLOAD_TOO_LARGE;
case 429 -> TOO_MANY_REQUESTS;
case 502 -> BAD_GATEWAY;
case 503 -> SERVICE_UNAVAILABLE;
case 504 -> GATEWAY_TIMEOUT;
default -> INTERNAL_SERVER_ERROR;
};
} }
public int getHttpStatus() { return httpStatus; } public int getHttpStatus() { return httpStatus; }

View File

@@ -0,0 +1,20 @@
package lib.exceptions;
import lib.constants.ApplicationMessage;
/** Single typed application exception carrying a safe user-facing message. */
public class ApplicationException extends RuntimeException {
private final ApplicationMessage applicationMessage;
public ApplicationException(ApplicationMessage message, String technicalMessage) {
super(technicalMessage);
this.applicationMessage = message;
}
public ApplicationException(ApplicationMessage message, String technicalMessage, Throwable cause) {
super(technicalMessage, cause);
this.applicationMessage = message;
}
public ApplicationMessage getApplicationMessage() { return applicationMessage; }
}

View File

@@ -0,0 +1,12 @@
package lib.models;
/** Consistent API response contract for every migrated workflow. */
public record ApiResponse<T>(boolean success, T data, MessageDetails message) {
public static <T> ApiResponse<T> success(T data, MessageDetails message) {
return new ApiResponse<>(true, data, message);
}
public static <T> ApiResponse<T> failure(MessageDetails message) {
return new ApiResponse<>(false, null, message);
}
}

View File

@@ -0,0 +1,87 @@
package lib.models;
import java.io.Serial;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/** User-editable case initiation data. Identity and audit fields come from UserSession. */
@Getter
@Setter
public class ApplicationDetails implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private Integer applicationId;
private Short portfolioId;
private Short bankBranchId;
private String applicationNumber;
private String bankCode;
private String product;
private String loanAmount;
private String customerName;
private String fatherName;
private String applicationType;
private String category;
private String dateOfBirth;
private String contactPerson;
private String mobileNumber;
private String specialInstruction;
private Boolean residenceVerification;
private Boolean residenceTelephoneVerification;
private Boolean officeVerification;
private Boolean officeTelephoneVerification;
private Boolean propertyVerification;
private Boolean referenceVerification;
private Boolean documentVerification;
private Boolean residenceCoApplicant;
private Boolean residenceCoProprietor;
private Boolean officeCoProprietor;
private Boolean sameResidenceAddress;
private Boolean sameOfficeAddress;
private Boolean samePropertyAddress;
private String residenceAddress1;
private String residenceAddress2;
private String residenceAddress3;
private String residenceLandmark;
private Integer residenceColonyId;
private String residenceCity;
private String residencePincode;
private String residencePhone;
private String companyName;
private String officeAddress1;
private String officeAddress2;
private String officeAddress3;
private String officeLandmark;
private Integer officeColonyId;
private String officeCity;
private String officePincode;
private String department;
private String designation;
private String officePhone;
private String extension;
private String propertyAddress1;
private String propertyAddress2;
private String propertyAddress3;
private String propertyLandmark;
private Integer propertyColonyId;
private String propertyCity;
private String propertyPincode;
private String referenceName1;
private String referenceAddress1;
private String referenceContactNumber1;
private String referenceName2;
private String referenceAddress2;
private String referenceContactNumber2;
private Map<String, String> dynamicFields = new LinkedHashMap<>();
private Boolean autoCutOff;
private Integer formMode;
}

View File

@@ -0,0 +1,4 @@
package lib.models;
/** Data returned after saving a loan-verification application. */
public record ApplicationSaveData(String operation, Integer applicationId, String mvCode) {}

View File

@@ -0,0 +1,16 @@
package lib.models;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Result row returned by the application-save database operation. */
@Getter
@Setter
@NoArgsConstructor
public class ApplicationSaveResult {
private String operation;
private Integer applicationId;
private String mvCode;
private String internalUuid;
}

View File

@@ -9,7 +9,7 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class CasePunching { public class CasePunching {
private ErrorDetails errorDetails; private MessageDetails messageDetails;
private Map<String, String> optionsl; private Map<String, String> optionsl;
private Map<String, List<Option>> options; private Map<String, List<Option>> options;
private List<String> visibleSections; private List<String> visibleSections;

View File

@@ -0,0 +1,11 @@
package lib.models;
/** Hybrid RSA-OAEP/AES-GCM envelope submitted by the initiation page. */
public record CaseSaveRequest(
String keyId,
String encryptedKey,
String initializationVector,
String encryptedPayload,
String requestId,
String timestamp) {
}

View File

@@ -1,6 +1,6 @@
package lib.models; package lib.models;
import lib.constants.ApplicationError; import lib.constants.ApplicationMessage;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.Instant; import java.time.Instant;
@@ -10,37 +10,37 @@ import lombok.Getter;
/** Dependency-free error information shared across Cygnus modules. */ /** Dependency-free error information shared across Cygnus modules. */
@Getter @Getter
public final class ErrorDetails implements Serializable { public final class MessageDetails implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private final int httpStatus; private final int httpStatus;
private final String errorCode; private final String code;
private final String title; private final String title;
private final String message; private final String message;
private final String description; private final String description;
private final String referenceId; private final String referenceId;
private final Instant timestamp; private final Instant timestamp;
public ErrorDetails(ApplicationError error) { public MessageDetails(ApplicationMessage message) {
this(error.getHttpStatus(), error.getCode(), error.getTitle(), this(message.getHttpStatus(), message.getCode(), message.getTitle(),
error.getMessage(), error.getDescription(), message.getMessage(), message.getDescription(),
UUID.randomUUID().toString(), Instant.now()); UUID.randomUUID().toString(), Instant.now());
} }
private ErrorDetails( private MessageDetails(
int httpStatus, int httpStatus,
String errorCode, String code,
String title, String title,
String message, String message,
String description, String description,
String referenceId, String referenceId,
Instant timestamp) { Instant timestamp) {
if (httpStatus < 400 || httpStatus > 599) { if (httpStatus < 100 || httpStatus > 599) {
throw new IllegalArgumentException("HTTP error status must be between 400 and 599"); throw new IllegalArgumentException("HTTP status must be between 100 and 599");
} }
this.httpStatus = httpStatus; this.httpStatus = httpStatus;
this.errorCode = required(errorCode, "errorCode"); this.code = required(code, "code");
this.title = required(title, "title"); this.title = required(title, "title");
this.message = required(message, "message"); this.message = required(message, "message");
this.description = description == null ? "" : description.trim(); this.description = description == null ? "" : description.trim();

View File

@@ -1,22 +0,0 @@
package lib.models;
import lib.constants.ApplicationError;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class ErrorDetailsTest {
@Test
void createsAReusableErrorContract() {
ErrorDetails details = new ErrorDetails(ApplicationError.ACCESS_DENIED);
assertEquals(403, details.getHttpStatus());
assertEquals("AUTH-403", details.getErrorCode());
}
@Test
void mapsHttpStatusToCanonicalError() {
assertEquals(ApplicationError.PAGE_NOT_FOUND, ApplicationError.fromHttpStatus(404));
assertEquals(ApplicationError.INTERNAL_SERVER_ERROR,
ApplicationError.fromHttpStatus(599));
}
}

View File

@@ -0,0 +1,22 @@
package lib.models;
import lib.constants.ApplicationMessage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class MessageDetailsTest {
@Test
void createsAReusableErrorContract() {
MessageDetails details = new MessageDetails(ApplicationMessage.ACCESS_DENIED);
assertEquals(403, details.getHttpStatus());
assertEquals("AUTH-403", details.getCode());
}
@Test
void mapsHttpStatusToCanonicalError() {
assertEquals(ApplicationMessage.PAGE_NOT_FOUND, ApplicationMessage.fromHttpStatus(404));
assertEquals(ApplicationMessage.INTERNAL_SERVER_ERROR,
ApplicationMessage.fromHttpStatus(599));
}
}

Binary file not shown.

View File

@@ -1,3 +1,12 @@
lib/models/ErrorDetails.class lib/models/MessageDetails.class
lib/models/CasePunching.class lib/models/CasePunching.class
lib/constants/ApplicationError.class lib/exceptions/ApplicationException.class
lib/models/Option.class
lib/models/UserSession.class
lib/models/ApiResponse.class
lib/models/ApplicationSaveResult.class
lib/constants/ApplicationMessage.class
lib/models/ApplicationSaveData.class
lib/models/UserSession$UserSessionBuilder.class
lib/models/CaseSaveRequest.class
lib/models/ApplicationDetails.class

View File

@@ -1,5 +1,11 @@
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/constants/ApplicationError.java /Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/constants/ApplicationMessage.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/exceptions/ApplicationException.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/ApiResponse.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/ApplicationDetails.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/ApplicationSaveData.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/ApplicationSaveResult.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/CasePunching.java /Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/CasePunching.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/ErrorDetails.java /Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/CaseSaveRequest.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/MessageDetails.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/Option.java /Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/Option.java
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/UserSession.java /Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/main/java/lib/models/UserSession.java

View File

@@ -1 +1 @@
lib/models/ErrorDetailsTest.class lib/models/MessageDetailsTest.class

View File

@@ -1 +1 @@
/Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/test/java/lib/models/ErrorDetailsTest.java /Users/maddy/Projects/cygnus/matrix/cygnus-lib/src/test/java/lib/models/MessageDetailsTest.java

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report.xsd" version="3.0.2" name="lib.models.ErrorDetailsTest" time="0.028" tests="2" errors="0" skipped="0" failures="0">
<properties>
<property name="java.specification.version" value="21"/>
<property name="sun.jnu.encoding" value="UTF-8"/>
<property name="java.class.path" value="/Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/test-classes:/Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/classes:/Users/maddy/.m2/repository/org/projectlombok/lombok/1.18.46/lombok-1.18.46.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
<property name="java.vm.vendor" value="Microsoft"/>
<property name="sun.arch.data.model" value="64"/>
<property name="java.vendor.url" value="https://www.microsoft.com"/>
<property name="os.name" value="Mac OS X"/>
<property name="java.vm.specification.version" value="21"/>
<property name="sun.java.launcher" value="SUN_STANDARD"/>
<property name="user.country" value="US"/>
<property name="sun.boot.library.path" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home/lib"/>
<property name="sun.java.command" value="/Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/surefire/surefirebooter-20260801225315947_3.jar /Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/surefire 2026-08-01T22-53-15_920-jvmRun1 surefire-20260801225315947_1tmp surefire_0-20260801225315947_2tmp"/>
<property name="http.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="jdk.debug" value="release"/>
<property name="surefire.test.class.path" value="/Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/test-classes:/Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/classes:/Users/maddy/.m2/repository/org/projectlombok/lombok/1.18.46/lombok-1.18.46.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
<property name="sun.cpu.endian" value="little"/>
<property name="user.home" value="/Users/maddy"/>
<property name="user.language" value="en"/>
<property name="java.specification.vendor" value="Oracle Corporation"/>
<property name="java.version.date" value="2025-07-15"/>
<property name="java.home" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"/>
<property name="file.separator" value="/"/>
<property name="basedir" value="/Users/maddy/Projects/cygnus/matrix/cygnus-lib"/>
<property name="java.vm.compressedOopsMode" value="Zero based"/>
<property name="line.separator" value="&#10;"/>
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
<property name="java.specification.name" value="Java Platform API Specification"/>
<property name="apple.awt.application.name" value="ForkedBooter"/>
<property name="surefire.real.class.path" value="/Users/maddy/Projects/cygnus/matrix/cygnus-lib/target/surefire/surefirebooter-20260801225315947_3.jar"/>
<property name="sun.management.compiler" value="HotSpot 64-Bit Tiered Compilers"/>
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="java.runtime.version" value="21.0.8+9-LTS"/>
<property name="user.name" value="maddy"/>
<property name="stdout.encoding" value="UTF-8"/>
<property name="path.separator" value=":"/>
<property name="os.version" value="26.5.2"/>
<property name="java.runtime.name" value="OpenJDK Runtime Environment"/>
<property name="file.encoding" value="UTF-8"/>
<property name="java.vm.name" value="OpenJDK 64-Bit Server VM"/>
<property name="java.vendor.version" value="Microsoft-11933201"/>
<property name="localRepository" value="/Users/maddy/.m2/repository"/>
<property name="java.vendor.url.bug" value="https://github.com/microsoft/openjdk/issues"/>
<property name="java.io.tmpdir" value="/var/folders/1l/36214rdn79755j30lcnmgsqh0000gn/T/"/>
<property name="java.version" value="21.0.8"/>
<property name="user.dir" value="/Users/maddy/Projects/cygnus/matrix/cygnus-lib"/>
<property name="os.arch" value="aarch64"/>
<property name="java.vm.specification.name" value="Java Virtual Machine Specification"/>
<property name="native.encoding" value="UTF-8"/>
<property name="java.library.path" value="/Users/maddy/Library/Java/Extensions:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java:."/>
<property name="java.vm.info" value="mixed mode, sharing"/>
<property name="stderr.encoding" value="UTF-8"/>
<property name="java.vendor" value="Microsoft"/>
<property name="java.vm.version" value="21.0.8+9-LTS"/>
<property name="sun.io.unicode.encoding" value="UnicodeBig"/>
<property name="socksNonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="java.class.version" value="65.0"/>
</properties>
<testcase name="mapsHttpStatusToCanonicalError" classname="lib.models.ErrorDetailsTest" time="0.008"/>
<testcase name="createsAReusableErrorContract" classname="lib.models.ErrorDetailsTest" time="0.012"/>
</testsuite>

View File

@@ -1,4 +0,0 @@
-------------------------------------------------------------------------------
Test set: lib.models.ErrorDetailsTest
-------------------------------------------------------------------------------
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s -- in lib.models.ErrorDetailsTest

View File

@@ -18,9 +18,11 @@
<script language="javascript" src="/matrix/js/jquery1.7.2.js" type="text/javascript"></script> <script language="javascript" src="/matrix/js/jquery1.7.2.js" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/ui/uifuncs.js" type="text/javascript"></script> <script language="javascript" src="/matrix/js/ui/uifuncs.js" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/lib/jsfuncs.js" type="text/javascript"></script> <script language="javascript" src="/matrix/js/lib/jsfuncs.js" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/lib/payload-crypto.js?ver=1" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/ui/msgdialog.js" type="text/javascript"></script> <script language="javascript" src="/matrix/js/ui/msgdialog.js" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/ui/select.js" type="text/javascript"></script> <script language="javascript" src="/matrix/js/ui/select.js" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/appjs/punching.js?ver=6" type="text/javascript"></script> <script language="javascript" src="/matrix/js/appjs/punching.js?ver=6" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/edp/initiation.js?ver=1" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/validator.js?ver=2" type="text/javascript"></script> <script language="javascript" src="/matrix/js/validator.js?ver=2" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/ajax.js" type="text/javascript"></script> <script language="javascript" src="/matrix/js/ajax.js" type="text/javascript"></script>
<script language="javascript" src="/matrix/js/ajax-dynamic-list.js?ver=1" type="text/javascript"></script> <script language="javascript" src="/matrix/js/ajax-dynamic-list.js?ver=1" type="text/javascript"></script>
@@ -701,7 +703,7 @@
<input type="button" class="button" name="btnprev" style="margin-top:-2px;float:left;width:80px" value="" id="btnprev" accesskey="P" onclick="findRecord(2)" /> <input type="button" class="button" name="btnprev" style="margin-top:-2px;float:left;width:80px" value="" id="btnprev" accesskey="P" onclick="findRecord(2)" />
<input type="button" class="button" name="btnnext" style="margin-top:-2px;float:left;width:80px" value="" id="btnnext" accesskey="N" onclick="findRecord(3)" /> <input type="button" class="button" name="btnnext" style="margin-top:-2px;float:left;width:80px" value="" id="btnnext" accesskey="N" onclick="findRecord(3)" />
<input type="button" class="button" name="btnlast" style="margin-top:-2px;float:left;width:80px" value="" id="btnlast" accesskey="L" onclick="findRecord(4)" /> <input type="button" class="button" name="btnlast" style="margin-top:-2px;float:left;width:80px" value="" id="btnlast" accesskey="L" onclick="findRecord(4)" />
<input type="button" class="button" name="btnsave" style="float:right" value="Save" id="btnsave" accesskey="S" onclick="return ValidateSubmitForm('casedetails');" /> <input type="button" class="button" name="btnsave" style="float:right" value="Save" id="btnsave" accesskey="S" onclick="return CygnusInitiation.save(event);" />
<c:if test="${pageFormMode!=2}"> <c:if test="${pageFormMode!=2}">
<input type="button" class="button" name="btnadd" style="float:right" value="Add" id="btnadd" accesskey="A" onclick="return AddNewRecord();" /> <input type="button" class="button" name="btnadd" style="float:right" value="Add" id="btnadd" accesskey="A" onclick="return AddNewRecord();" />
</c:if> </c:if>
@@ -710,9 +712,9 @@
</form> </form>
<input type="hidden" id="formmode" name="formmode" value="${pageFormMode}" /> <input type="hidden" id="formmode" name="formmode" value="${pageFormMode}" />
<input type="hidden" id="usid" name="usid" value="${pageUserId}" /> <input type="hidden" id="usid" name="usid" value="${pageUserId}" />
<c:if test="${not empty model.errorDetails}"> <c:if test="${not empty model.messageDetails}">
<span id="page-error-code" hidden><c:out value="${model.errorDetails.errorCode}" /></span> <span id="page-error-code" hidden><c:out value="${model.messageDetails.code}" /></span>
<span id="page-error-message" hidden><c:out value="${model.errorDetails.message}" /></span> <span id="page-error-message" hidden><c:out value="${model.messageDetails.message}" /></span>
</c:if> </c:if>
</div> </div>
</div> </div>
@@ -728,10 +730,10 @@
} }
InitPage(); InitPage();
var errorCode = document.getElementById("page-error-code"); var code = document.getElementById("page-error-code");
var errorMessage = document.getElementById("page-error-message"); var errorMessage = document.getElementById("page-error-message");
if (errorCode && errorMessage) { if (code && errorMessage) {
CallMessage(errorCode.textContent + ":error:" + errorMessage.textContent, 3000, 200, 300); CallMessage(code.textContent + ":error:" + errorMessage.textContent, 3000, 200, 300);
} }
}()); }());
</script> </script>

View File

@@ -10,9 +10,9 @@
<link href="<c:url value='/css/matrix-shell-v2.css' />" rel="stylesheet" /> <link href="<c:url value='/css/matrix-shell-v2.css' />" rel="stylesheet" />
<link href="<c:url value='/css/matrix-theme-v2.css' />" rel="stylesheet" /> <link href="<c:url value='/css/matrix-theme-v2.css' />" rel="stylesheet" />
<script src="<c:url value='/js/matrix-shell-v2.js' />" defer></script> <script src="<c:url value='/js/matrix-shell-v2.js' />" defer></script>
<title>Cygnus 1.0 | <c:out value="${empty errorDetails.title ? 'Page unavailable' : errorDetails.title}" /></title> <title>Cygnus 1.0 | <c:out value="${empty messageDetails.title ? 'Page unavailable' : messageDetails.title}" /></title>
</head> </head>
<body class="matrix-v2 matrix-shell matrix-error-page matrix-error-page--${empty errorDetails.httpStatus ? 404 : errorDetails.httpStatus}"> <body class="matrix-v2 matrix-shell matrix-error-page matrix-error-page--${empty messageDetails.httpStatus ? 404 : messageDetails.httpStatus}">
<c:choose> <c:choose>
<c:when test="${not empty Sessvals and not empty Sessvals.menuHtml}"> <c:when test="${not empty Sessvals and not empty Sessvals.menuHtml}">
<%@ include file="/WEB-INF/app/fragments/app-shell-header.jspf" %> <%@ include file="/WEB-INF/app/fragments/app-shell-header.jspf" %>
@@ -25,20 +25,20 @@
<main class="matrix-error-layout" id="mainContent"> <main class="matrix-error-layout" id="mainContent">
<section class="matrix-error-card" role="alert" aria-labelledby="errorTitle"> <section class="matrix-error-card" role="alert" aria-labelledby="errorTitle">
<div class="matrix-error-card__status" aria-hidden="true"> <div class="matrix-error-card__status" aria-hidden="true">
<span><c:out value="${empty errorDetails.httpStatus ? 404 : errorDetails.httpStatus}" /></span> <span><c:out value="${empty messageDetails.httpStatus ? 404 : messageDetails.httpStatus}" /></span>
</div> </div>
<div class="matrix-error-card__content"> <div class="matrix-error-card__content">
<span class="matrix-error-card__eyebrow"> <span class="matrix-error-card__eyebrow">
Error <c:out value="${empty errorDetails.errorCode ? 'HTTP-404' : errorDetails.errorCode}" /> Error <c:out value="${empty messageDetails.code ? 'HTTP-404' : messageDetails.code}" />
</span> </span>
<h1 id="errorTitle"> <h1 id="errorTitle">
<c:out value="${empty errorDetails.title ? 'Page unavailable' : errorDetails.title}" /> <c:out value="${empty messageDetails.title ? 'Page unavailable' : messageDetails.title}" />
</h1> </h1>
<p class="matrix-error-card__message"> <p class="matrix-error-card__message">
<c:out value="${empty errorDetails.message ? 'The requested page is unavailable.' : errorDetails.message}" /> <c:out value="${empty messageDetails.message ? 'The requested page is unavailable.' : messageDetails.message}" />
</p> </p>
<c:if test="${not empty errorDetails.description}"> <c:if test="${not empty messageDetails.description}">
<p class="matrix-error-card__description"><c:out value="${errorDetails.description}" /></p> <p class="matrix-error-card__description"><c:out value="${messageDetails.description}" /></p>
</c:if> </c:if>
<div class="matrix-error-card__actions"> <div class="matrix-error-card__actions">
@@ -55,9 +55,9 @@
</c:choose> </c:choose>
</div> </div>
<c:if test="${not empty errorDetails.referenceId}"> <c:if test="${not empty messageDetails.referenceId}">
<p class="matrix-error-card__reference"> <p class="matrix-error-card__reference">
Reference ID: <code><c:out value="${errorDetails.referenceId}" /></code> Reference ID: <code><c:out value="${messageDetails.referenceId}" /></code>
</p> </p>
</c:if> </c:if>
</div> </div>

View File

@@ -63,6 +63,7 @@
.matrix-add-cases .matrix-form-controls textarea { .matrix-add-cases .matrix-form-controls textarea {
width: 100% !important; width: 100% !important;
min-height: 30px; min-height: 30px;
font-weight: 400;
} }
.matrix-add-cases .matrix-form-controls .widget:has(#splist), .matrix-add-cases .matrix-form-controls .widget:has(#splist),

View File

@@ -0,0 +1,170 @@
(function (window, document) {
"use strict";
const API = Object.freeze({ key: "payload-key", save: "application-save" });
const BOOLEAN_FIELDS = Object.freeze({
residenceVerification: "rv",
residenceTelephoneVerification: "rtv",
officeVerification: "ov",
officeTelephoneVerification: "otv",
propertyVerification: "pv",
referenceVerification: "refv",
documentVerification: "docv",
residenceCoApplicant: "rco",
residenceCoProprietor: "rcp",
officeCoProprietor: "ocp",
sameResidenceAddress: "sradd",
sameOfficeAddress: "soadd",
samePropertyAddress: "spadd",
autoCutOff: "autocut"
});
const VALUE_FIELDS = Object.freeze({
applicationId: "case_id", portfolioId: "portfolio_id", bankBranchId: "bank_branch_id",
applicationNumber: "applno", bankCode: "bankcode", product: "product",
loanAmount: "loanamount", customerName: "customername", fatherName: "fathername",
applicationType: "apptype", category: "category", dateOfBirth: "dob",
contactPerson: "contactperson", mobileNumber: "mobileno", specialInstruction: "specialinst",
residenceAddress1: "raddr1", residenceAddress2: "raddr2", residenceAddress3: "raddr3",
residenceLandmark: "rlandmark", residenceColonyId: "Rcolony", residenceCity: "rcity",
residencePincode: "rpincode", residencePhone: "rphone", companyName: "companyname",
officeAddress1: "oaddr1", officeAddress2: "oaddr2", officeAddress3: "oaddr3",
officeLandmark: "olandmark", officeColonyId: "Ocolony", officeCity: "ocity",
officePincode: "opincode", department: "department", designation: "designation",
officePhone: "ophone", extension: "extension", propertyAddress1: "paddr1",
propertyAddress2: "paddr2", propertyAddress3: "paddr3", propertyLandmark: "plandmark",
propertyColonyId: "Pcolony", propertyCity: "pcity", propertyPincode: "ppincode",
referenceName1: "refname1", referenceAddress1: "refaddress1",
referenceContactNumber1: "refcontactno1", referenceName2: "refname2",
referenceAddress2: "refaddress2", referenceContactNumber2: "refcontactno2",
formMode: "formmode"
});
const INTEGER_FIELDS = new Set(["applicationId", "portfolioId", "bankBranchId", "residenceColonyId",
"officeColonyId", "propertyColonyId", "formMode"]);
const element = (id) => document.getElementById(id);
const value = (id) => element(id)?.value?.trim() ?? "";
const integer = (id) => {
const parsed = Number.parseInt(value(id), 10);
return Number.isFinite(parsed) ? parsed : 0;
};
const checked = (id) => Boolean(element(id)?.checked);
function collectDynamicFields() {
const names = value("dynamicfields").split(window.ColDelim || "!C0L!").filter(Boolean);
return names.reduce((result, name) => {
if (/^(?:field(?:[1-9]|1[0-2])|dtfield1)$/.test(name) && element(name)) {
result[name] = value(name);
}
return result;
}, {});
}
function collectCase() {
const details = {};
Object.entries(VALUE_FIELDS).forEach(([property, id]) => {
details[property] = INTEGER_FIELDS.has(property) ? integer(id) : value(id);
});
Object.entries(BOOLEAN_FIELDS).forEach(([property, id]) => { details[property] = checked(id); });
details.dynamicFields = collectDynamicFields();
return details;
}
function validateForm() {
const verificationIds = ["rv", "rtv", "ov", "otv", "pv", "refv", "docv"];
if (!verificationIds.some(checked)) {
showAlert("danger", "APP-4221", "At least one verification type is required.");
return false;
}
window.invalidFields = 0;
window.validate(element("applno"), "t", "AlphaNumeric");
window.validate(element("bank_branch_id"), "t", "");
window.validate(element("product"), "t", "");
window.validate(element("customername"), "t", "AlphaSpace");
window.validate(element("apptype"), "t", "");
if (checked("rv")) window.CheckForm(element("rv"), "raddr1,raddr2,raddr3,rlandmark,colr,rcity,rpincode", "t,f,t,f,t,t,f", "SplInstruction,SplInstruction,SplInstruction,SplInstruction,SplInstruction,,Pincode");
if (checked("ov")) window.CheckForm(element("ov"), "companyname,oaddr1,oaddr2,oaddr3,olandmark,colo,ocity,opincode,department,designation", "f,t,f,t,f,t,t,f,f,f", "SplInstruction,SplInstruction,SplInstruction,SplInstruction,SplInstruction,SplInstruction,,Pincode,SplInstruction,SplInstruction");
if (checked("pv")) window.CheckForm(element("pv"), "paddr1,paddr2,paddr3,plandmark,colp,pcity,ppincode", "t,f,t,f,t,t,f", "SplInstruction,SplInstruction,SplInstruction,SplInstruction,SplInstruction,,Pincode");
if (checked("rtv")) window.CheckForm(element("rtv"), "rphone", "t", "Phone");
if (checked("otv")) window.CheckForm(element("otv"), "ophone", "t", "Phone");
if (checked("refv")) window.CheckForm(element("refv"), "refname1,refaddress1,refcontactno1", "t,f,t", "AlphaSpace,SplInstruction,Phone");
[["rv", "Rcolony", "colr"], ["ov", "Ocolony", "colo"], ["pv", "Pcolony", "colp"]]
.forEach(([flag, hidden, visible]) => {
if (checked(flag) && integer(hidden) === 0) window.highlightField(element(visible));
});
if (document.querySelector(".matrix-validation-error-icon")) {
showAlert("danger", "APP-4221", "Correct the highlighted fields and submit the application again.");
return false;
}
return true;
}
function showAlert(level, code, message, referenceId) {
let host = element("case-save-alerts");
if (!host) {
host = document.createElement("div");
host.id = "case-save-alerts";
host.className = "position-fixed top-0 start-50 translate-middle-x p-3";
host.style.cssText = "z-index:1085;max-width:720px;width:calc(100% - 2rem)";
document.body.append(host);
}
const alert = document.createElement("div");
alert.className = `alert alert-${level} alert-dismissible fade show shadow-sm`;
alert.setAttribute("role", "alert");
const strong = document.createElement("strong");
strong.textContent = `${code}: `;
alert.append(strong, document.createTextNode(message));
if (referenceId) {
const reference = document.createElement("small");
reference.className = "d-block mt-1";
reference.textContent = `Reference: ${referenceId}`;
alert.append(reference);
}
const close = document.createElement("button");
close.type = "button";
close.className = "btn-close";
close.setAttribute("data-bs-dismiss", "alert");
close.setAttribute("aria-label", "Close");
alert.append(close);
host.replaceChildren(alert);
window.setTimeout(() => window.bootstrap?.Alert.getOrCreateInstance(alert).close(), 7000);
}
function applyResult(result, originalApplicationId) {
element("case_id").value = result.applicationId;
if (element("mvcode")) element("mvcode").value = result.mvCode || "";
if (Array.isArray(window.fields?.[0]) && Array.isArray(window.records?.[window.recpos])) {
[["case_id", result.applicationId], ["mvcode", result.mvCode]].forEach(([name, fieldValue]) => {
const index = window.fields[0].indexOf(name);
if (index >= 0) window.records[window.recpos][index] = fieldValue;
});
}
showAlert("success", "APP-2001", `Application ${result.mvCode || result.applicationId} saved successfully.`);
if (originalApplicationId === 0 && typeof window.AddNewRecord === "function") window.AddNewRecord();
else if (originalApplicationId > 0 && typeof window.findRecord === "function") window.findRecord(3);
}
async function save(event) {
event?.preventDefault();
if (!validateForm()) return false;
const button = element("btnsave");
if (button?.disabled) return false;
const details = collectCase();
if (button) button.disabled = true;
try {
const envelope = await window.CygnusPayloadCrypto.encrypt(details, { keyUrl: API.key });
const response = await fetch(API.save, { method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(envelope) });
const body = await response.json().catch(() => null);
if (!response.ok || !body?.success) throw body?.message || body || {};
applyResult(body.data, details.applicationId);
} catch (error) {
showAlert("danger", error.code || "APP-5001",
error.message || "Cygnus could not save the application details.", error.referenceId);
} finally {
if (button) button.disabled = false;
}
return false;
}
window.CygnusInitiation = Object.freeze({ save });
}(window, document));

View File

@@ -0,0 +1,91 @@
(function (window) {
"use strict";
const keyRequests = new Map();
const encoder = new TextEncoder();
function toBase64Url(bytes) {
let binary = "";
const view = new Uint8Array(bytes);
for (let offset = 0; offset < view.length; offset += 0x8000) {
binary += String.fromCharCode(...view.subarray(offset, offset + 0x8000));
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function fromBase64Url(text) {
const normalized = text.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function fetchKey(keyUrl) {
if (!keyUrl) {
return Promise.reject(new Error("A payload encryption key URL is required."));
}
if (!keyRequests.has(keyUrl)) {
const request = fetch(keyUrl, {
credentials: "same-origin",
headers: { Accept: "application/json" }
}).then(async (response) => {
const body = await response.json();
if (!response.ok) throw body;
if (!body.keyId || !body.publicKey) {
throw new Error("The payload encryption key response is invalid.");
}
return body;
}).catch((error) => {
keyRequests.delete(keyUrl);
throw error;
});
keyRequests.set(keyUrl, request);
}
return keyRequests.get(keyUrl);
}
async function encrypt(payload, options) {
if (!window.crypto?.subtle) {
throw new Error("Secure payload encryption is not supported by this browser.");
}
const keyInfo = await fetchKey(options?.keyUrl);
const publicKey = await window.crypto.subtle.importKey(
"spki",
fromBase64Url(keyInfo.publicKey),
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const aesKey = await window.crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const initializationVector = window.crypto.getRandomValues(new Uint8Array(12));
const requestId = window.crypto.randomUUID();
const timestamp = new Date().toISOString();
const additionalData = encoder.encode(`${keyInfo.keyId}|${requestId}|${timestamp}`);
const encryptedPayload = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: initializationVector, additionalData },
aesKey,
encoder.encode(JSON.stringify(payload))
);
const rawKey = await window.crypto.subtle.exportKey("raw", aesKey);
const encryptedKey = await window.crypto.subtle.encrypt(
{ name: "RSA-OAEP" }, publicKey, rawKey
);
return Object.freeze({
keyId: keyInfo.keyId,
encryptedKey: toBase64Url(encryptedKey),
initializationVector: toBase64Url(initializationVector),
encryptedPayload: toBase64Url(encryptedPayload),
requestId,
timestamp
});
}
function clearKeyCache(keyUrl) {
if (keyUrl) keyRequests.delete(keyUrl);
else keyRequests.clear();
}
window.CygnusPayloadCrypto = Object.freeze({ encrypt, clearKeyCache });
}(window));

View File

@@ -2,8 +2,8 @@ package matrix.nimble.controller;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import lib.constants.ApplicationError; import lib.constants.ApplicationMessage;
import lib.models.ErrorDetails; import lib.models.MessageDetails;
import lib.models.UserSession; import lib.models.UserSession;
import matrix.services.commons.CommonErrorService; import matrix.services.commons.CommonErrorService;
import matrix.services.commons.CommonService; import matrix.services.commons.CommonService;
@@ -28,22 +28,34 @@ public abstract class AbstractAuthenticatedController {
HttpServletResponse response) { HttpServletResponse response) {
UserSession session = commonService.getUserSession(httpSession); UserSession session = commonService.getUserSession(httpSession);
if (session == null) { if (session == null) {
return denied(model, response, httpSession, ApplicationError.SESSION_REQUIRED); return denied(model, response, httpSession, ApplicationMessage.SESSION_REQUIRED);
} }
if (!commonService.hasPageAccess(session, pageRoute)) { if (!commonService.hasPageAccess(session, pageRoute)) {
return denied(model, response, httpSession, ApplicationError.ACCESS_DENIED); return denied(model, response, httpSession, ApplicationMessage.ACCESS_DENIED);
} }
model.addAttribute(CommonService.USER_SESSION_ATTRIBUTE, session); model.addAttribute(CommonService.USER_SESSION_ATTRIBUTE, session);
return PageAuthorization.granted(session); return PageAuthorization.granted(session);
} }
protected final ApiAuthorization authorizeApiPage(
String pageRoute, HttpSession httpSession) {
UserSession session = commonService.getUserSession(httpSession);
if (session == null) {
return ApiAuthorization.denied(new MessageDetails(ApplicationMessage.SESSION_REQUIRED));
}
if (!commonService.hasPageAccess(session, pageRoute)) {
return ApiAuthorization.denied(new MessageDetails(ApplicationMessage.ACCESS_DENIED));
}
return ApiAuthorization.granted(session);
}
private PageAuthorization denied( private PageAuthorization denied(
ModelMap model, ModelMap model,
HttpServletResponse response, HttpServletResponse response,
HttpSession httpSession, HttpSession httpSession,
ApplicationError error) { ApplicationMessage error) {
String viewName = errorService.render( String viewName = errorService.render(
model, response, httpSession, new ErrorDetails(error)); model, response, httpSession, new MessageDetails(error));
return PageAuthorization.denied(viewName); return PageAuthorization.denied(viewName);
} }
@@ -60,4 +72,16 @@ public abstract class AbstractAuthenticatedController {
return session != null; return session != null;
} }
} }
protected record ApiAuthorization(UserSession session, MessageDetails error) {
private static ApiAuthorization granted(UserSession session) {
return new ApiAuthorization(session, null);
}
private static ApiAuthorization denied(MessageDetails error) {
return new ApiAuthorization(null, error);
}
public boolean isGranted() { return session != null; }
}
} }

View File

@@ -4,8 +4,8 @@ import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import lib.models.ErrorDetails; import lib.models.MessageDetails;
import lib.constants.ApplicationError; import lib.constants.ApplicationMessage;
import matrix.services.commons.CommonErrorService; import matrix.services.commons.CommonErrorService;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
@@ -37,7 +37,7 @@ public class ApplicationErrorController {
return HttpServletResponse.SC_INTERNAL_SERVER_ERROR; return HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
} }
private ErrorDetails details(int status) { private MessageDetails details(int status) {
return new ErrorDetails(ApplicationError.fromHttpStatus(status)); return new MessageDetails(ApplicationMessage.fromHttpStatus(status));
} }
} }

View File

@@ -6,6 +6,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@@ -15,17 +19,78 @@ import matrix.services.commons.CommonService;
import matrix.services.edp.PunchingService; import matrix.services.edp.PunchingService;
import matrix.services.commons.CommonErrorService; import matrix.services.commons.CommonErrorService;
import lib.models.CasePunching; import lib.models.CasePunching;
import lib.models.ApplicationDetails;
import lib.models.ApiResponse;
import lib.models.ApplicationSaveData;
import lib.models.ApplicationSaveResult;
import lib.models.CaseSaveRequest;
import lib.models.MessageDetails;
import lib.constants.ApplicationMessage;
import matrix.nimble.security.PayloadCryptoService;
import lib.exceptions.ApplicationException;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
@Controller @Controller
public class PunchingController extends AbstractAuthenticatedController { public class PunchingController extends AbstractAuthenticatedController {
private static final Logger LOGGER = Logger.getLogger(PunchingController.class.getName());
private static final String INIT_VIEW_ROUTE = "initview"; private static final String INIT_VIEW_ROUTE = "initview";
private static final String CASE_ADD_ROUTE = "caseadd";
private static final String CASE_EDIT_ROUTE = "caseedit";
private final PunchingService punchingService; private final PunchingService punchingService;
private final PayloadCryptoService cryptoService;
public PunchingController(CommonService commonService, CommonErrorService errorService, public PunchingController(CommonService commonService, CommonErrorService errorService,
PunchingService punchingService) { PunchingService punchingService, PayloadCryptoService cryptoService) {
super(commonService, errorService); super(commonService, errorService);
this.punchingService = punchingService; this.punchingService = punchingService;
this.cryptoService = cryptoService;
}
@RequestMapping(value = "payload-key", method = RequestMethod.GET,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public ResponseEntity<?> caseSaveKey(HttpSession httpSession) {
ApiAuthorization authorization = authorizeApiPage(INIT_VIEW_ROUTE, httpSession);
if (!authorization.isGranted()) return error(authorization.error());
return ResponseEntity.ok(Map.of(
"keyId", cryptoService.keyId(),
"algorithm", "RSA-OAEP-256",
"publicKey", cryptoService.encodedPublicKey()));
}
@RequestMapping(value = "application-save", method = RequestMethod.POST,
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public ResponseEntity<ApiResponse<ApplicationSaveData>> saveApplication(
@RequestBody CaseSaveRequest request, HttpSession httpSession) {
ApiAuthorization authorization = authorizeApiPage(INIT_VIEW_ROUTE, httpSession);
if (!authorization.isGranted()) return applicationError(authorization.error());
try {
ApplicationDetails details = cryptoService.decrypt(request, ApplicationDetails.class);
ApplicationSaveResult saved = punchingService.saveApplication(details, authorization.session());
return ResponseEntity.ok(ApiResponse.success(
new ApplicationSaveData(saved.getOperation(), saved.getApplicationId(), saved.getMvCode()),
new MessageDetails(ApplicationMessage.APPLICATION_SAVED)));
} catch (ApplicationException exception) {
LOGGER.log(Level.WARNING, "Application save was rejected", exception);
return applicationError(new MessageDetails(exception.getApplicationMessage()));
} catch (RuntimeException exception) {
LOGGER.log(Level.SEVERE, "Unexpected case-save failure", exception);
return applicationError(new MessageDetails(ApplicationMessage.APPLICATION_SAVE_FAILED));
}
}
private ResponseEntity<?> error(MessageDetails details) {
return ResponseEntity.status(details.getHttpStatus()).body(details);
}
private ResponseEntity<ApiResponse<ApplicationSaveData>> applicationError(MessageDetails details) {
return ResponseEntity.status(details.getHttpStatus())
.body(ApiResponse.failure(details));
} }
@RequestMapping(value = INIT_VIEW_ROUTE, method = RequestMethod.POST) @RequestMapping(value = INIT_VIEW_ROUTE, method = RequestMethod.POST)
@@ -45,8 +110,8 @@ public class PunchingController extends AbstractAuthenticatedController {
return "edp/punching/initcase"; return "edp/punching/initcase";
} }
@RequestMapping(value = "caseadd", method = RequestMethod.POST) @RequestMapping(value = CASE_ADD_ROUTE, method = RequestMethod.POST)
public String addCase( public String prepareNewApplication(
@ModelAttribute("model") CasePunching submitted, @ModelAttribute("model") CasePunching submitted,
ModelMap model, ModelMap model,
HttpSession httpSession, HttpSession httpSession,
@@ -58,13 +123,13 @@ public class PunchingController extends AbstractAuthenticatedController {
} }
UserSession session = authorization.session(); UserSession session = authorization.session();
model.addAttribute("model", punchingService.addCase( model.addAttribute("model", punchingService.prepareNewApplication(
submitted, session.getBranchId(), session.getCompanyId(), session.getUserId())); submitted, session.getBranchId(), session.getCompanyId(), session.getUserId()));
return "edp/punching/initcase"; return "edp/punching/initcase";
} }
@RequestMapping(value = "caseedit", method = RequestMethod.POST) @RequestMapping(value = CASE_EDIT_ROUTE, method = RequestMethod.POST)
public String editCase( public String prepareApplicationEdit(
@RequestParam("PortfolioId") Short portfolioId, @RequestParam("PortfolioId") Short portfolioId,
@RequestParam("uuid") String documentCaseId, @RequestParam("uuid") String documentCaseId,
ModelMap model, ModelMap model,
@@ -77,7 +142,7 @@ public class PunchingController extends AbstractAuthenticatedController {
} }
UserSession session = authorization.session(); UserSession session = authorization.session();
model.addAttribute("model", punchingService.editCase( model.addAttribute("model", punchingService.prepareApplicationEdit(
portfolioId, documentCaseId, session.getBranchId(), portfolioId, documentCaseId, session.getBranchId(),
session.getCompanyId(), session.getUserId())); session.getCompanyId(), session.getUserId()));
return "edp/punching/initcase"; return "edp/punching/initcase";

View File

@@ -14,4 +14,10 @@ public final class CloudQueryDefinitionProvider implements QueryDefinitionProvid
public QueryDefinition get(int queryId) { public QueryDefinition get(int queryId) {
return QueryDefinition.parse(queryId, provider.getQuery(queryId)); return QueryDefinition.parse(queryId, provider.getQuery(queryId));
} }
@Override
public QueryDefinition get(String queryKey) {
int diagnosticId = Math.max(1, queryKey.hashCode() & Integer.MAX_VALUE);
return QueryDefinition.parse(diagnosticId, provider.getQuery(queryKey));
}
} }

View File

@@ -19,4 +19,11 @@ public final class CloudQuerySource implements QuerySource {
.orElseThrow(() -> new QueryProviderException( .orElseThrow(() -> new QueryProviderException(
"Cloud query was empty: " + queryId)); "Cloud query was empty: " + queryId));
} }
@Override
public String fetch(String queryKey) {
return client.fetchQuery(queryKey).map(response -> response.query()).blockOptional()
.filter(query -> !query.isBlank())
.orElseThrow(() -> new QueryProviderException("Cloud query was empty: " + queryKey));
}
} }

View File

@@ -1,6 +1,10 @@
package matrix.nimble.query; package matrix.nimble.query;
@FunctionalInterface
public interface QueryProvider { public interface QueryProvider {
String getQuery(int queryId); String getQuery(int queryId);
default String getQuery(String queryKey) {
throw new UnsupportedOperationException("Query keys are not supported by this provider");
}
} }

View File

@@ -4,4 +4,7 @@ package matrix.nimble.query;
public interface QuerySource { public interface QuerySource {
String fetch(int queryId); String fetch(int queryId);
default String fetch(String queryKey) {
throw new UnsupportedOperationException("Query keys are not supported by this source");
}
} }

View File

@@ -30,7 +30,18 @@ public final class RedisCachingQueryProvider implements QueryProvider {
@Override @Override
public String getQuery(int queryId) { public String getQuery(int queryId) {
String cacheKey = cacheKey(queryId); return get("id:" + cacheKey(queryId), () -> cloudSource.fetch(queryId));
}
@Override
public String getQuery(String queryKey) {
if (queryKey == null || !queryKey.matches("[A-Z][A-Z0-9_]{2,99}")) {
throw new IllegalArgumentException("Invalid query key");
}
return get("key:" + queryKey, () -> cloudSource.fetch(queryKey));
}
private String get(String cacheKey, java.util.function.Supplier<String> source) {
Optional<String> cached = cached(cacheKey); Optional<String> cached = cached(cacheKey);
if (cached.isPresent()) { if (cached.isPresent()) {
return cached.get(); return cached.get();
@@ -42,14 +53,14 @@ public final class RedisCachingQueryProvider implements QueryProvider {
return cached.get(); return cached.get();
} }
try { try {
String query = cloudSource.fetch(queryId); String query = source.get();
cache.put(cacheKey, cipher.encrypt(cacheKey, query), QUERY_TTL); cache.put(cacheKey, cipher.encrypt(cacheKey, query), QUERY_TTL);
return query; return query;
} catch (QueryProviderException exception) { } catch (QueryProviderException exception) {
throw exception; throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw new QueryProviderException( throw new QueryProviderException(
"Unable to retrieve query: " + queryId, exception); "Unable to retrieve query: " + cacheKey, exception);
} }
} }
} }

View File

@@ -0,0 +1,174 @@
package matrix.nimble.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.security.spec.MGF1ParameterSpec;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import lib.constants.ApplicationMessage;
import lib.exceptions.ApplicationException;
import lib.models.CaseSaveRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/** Thread-safe decryption for short-lived form envelopes created with Web Crypto. */
@Service
public final class PayloadCryptoService {
private static final Duration REQUEST_TTL = Duration.ofMinutes(5);
private static final int GCM_TAG_BITS = 128;
private final ObjectMapper objectMapper;
private final PrivateKey privateKey;
private final PublicKey publicKey;
private final String keyId;
private final Map<String, Instant> acceptedRequests = new ConcurrentHashMap<>();
public PayloadCryptoService(
ObjectMapper objectMapper,
@Value("${CYGNUS_PAYLOAD_PRIVATE_KEY:${CYGNUS_CASE_SAVE_PRIVATE_KEY:}}") String privateKeyLocation,
@Value("${CYGNUS_PAYLOAD_PUBLIC_KEY:${CYGNUS_CASE_SAVE_PUBLIC_KEY:}}") String publicKeyLocation) {
this.objectMapper = objectMapper;
KeyPair pair = loadOrGenerate(privateKeyLocation, publicKeyLocation);
this.privateKey = pair.getPrivate();
this.publicKey = pair.getPublic();
this.keyId = fingerprint(publicKey);
}
public String keyId() { return keyId; }
public String encodedPublicKey() {
return Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getEncoded());
}
public <T> T decrypt(CaseSaveRequest request, Class<T> payloadType) {
validateEnvelope(request);
Instant now = Instant.now();
Instant submittedAt;
try {
submittedAt = Instant.parse(request.timestamp());
} catch (DateTimeParseException exception) {
throw invalid("Invalid request timestamp", exception);
}
if (submittedAt.isBefore(now.minus(REQUEST_TTL))
|| submittedAt.isAfter(now.plusSeconds(30))) {
throw invalid("Expired request payload");
}
discardExpired(now);
if (acceptedRequests.putIfAbsent(request.requestId(), now) != null) {
throw invalid("Duplicate request payload");
}
try {
Base64.Decoder decoder = Base64.getUrlDecoder();
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
keyCipher.init(Cipher.DECRYPT_MODE, privateKey,
new OAEPParameterSpec("SHA-256", "MGF1",
MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
byte[] aesKey = keyCipher.doFinal(decoder.decode(request.encryptedKey()));
byte[] iv = decoder.decode(request.initializationVector());
if (iv.length != 12) throw invalid("Invalid initialization vector");
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
payloadCipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(GCM_TAG_BITS, iv));
payloadCipher.updateAAD(aad(request));
byte[] json = payloadCipher.doFinal(decoder.decode(request.encryptedPayload()));
return objectMapper.readValue(json, payloadType);
} catch (ApplicationException exception) {
throw exception;
} catch (Exception exception) {
acceptedRequests.remove(request.requestId());
throw invalid("Unable to decrypt payload", exception);
}
}
private byte[] aad(CaseSaveRequest request) {
return String.join("|", request.keyId(), request.requestId(), request.timestamp())
.getBytes(StandardCharsets.UTF_8);
}
private void validateEnvelope(CaseSaveRequest request) {
if (request == null || blank(request.keyId()) || blank(request.encryptedKey())
|| blank(request.initializationVector()) || blank(request.encryptedPayload())
|| blank(request.requestId()) || blank(request.timestamp())) {
throw invalid("Incomplete encrypted request");
}
if (!keyId.equals(request.keyId())) throw invalid("Unsupported encryption key");
}
private void discardExpired(Instant now) {
Instant cutoff = now.minus(REQUEST_TTL);
acceptedRequests.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff));
}
private KeyPair loadOrGenerate(String privateLocation, String publicLocation) {
if (blank(privateLocation) && blank(publicLocation)) {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
return generator.generateKeyPair();
} catch (Exception exception) {
throw new IllegalStateException("Unable to generate payload key pair", exception);
}
}
if (blank(privateLocation) || blank(publicLocation)) {
throw new IllegalStateException(
"Both CYGNUS_PAYLOAD_PRIVATE_KEY and CYGNUS_PAYLOAD_PUBLIC_KEY are required");
}
try {
KeyFactory factory = KeyFactory.getInstance("RSA");
return new KeyPair(
factory.generatePublic(new X509EncodedKeySpec(pem(publicLocation, "PUBLIC KEY"))),
factory.generatePrivate(new PKCS8EncodedKeySpec(pem(privateLocation, "PRIVATE KEY"))));
} catch (Exception exception) {
throw new IllegalStateException("Unable to load payload key pair", exception);
}
}
private byte[] pem(String location, String type) throws Exception {
String path = location.startsWith("file:") ? location.substring(5) : location;
String value = Files.readString(Path.of(path), StandardCharsets.US_ASCII)
.replace("-----BEGIN " + type + "-----", "")
.replace("-----END " + type + "-----", "")
.replaceAll("\\s", "");
return Base64.getDecoder().decode(value);
}
private String fingerprint(PublicKey key) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(key.getEncoded());
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception exception) {
throw new IllegalStateException("Unable to identify payload key", exception);
}
}
private boolean blank(String value) { return value == null || value.isBlank(); }
private ApplicationException invalid(String technicalMessage) {
return new ApplicationException(ApplicationMessage.PAYLOAD_INVALID, technicalMessage);
}
private ApplicationException invalid(String technicalMessage, Throwable cause) {
return new ApplicationException(ApplicationMessage.PAYLOAD_INVALID, technicalMessage, cause);
}
}

View File

@@ -0,0 +1,16 @@
package matrix.nimble.security;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebJsonConfiguration {
@Bean
ObjectMapper webObjectMapper() {
return new ObjectMapper()
.findAndRegisterModules()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}
}

View File

@@ -2,14 +2,14 @@ package matrix.services.commons;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import lib.models.ErrorDetails; import lib.models.MessageDetails;
import matrix.nimble.model.Session; import matrix.nimble.model.Session;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.ui.ModelMap; import org.springframework.ui.ModelMap;
@Service @Service
public class CommonErrorService { public class CommonErrorService {
public static final String ERROR_ATTRIBUTE = "errorDetails"; public static final String ERROR_ATTRIBUTE = "messageDetails";
private final CommonService commonService; private final CommonService commonService;
public CommonErrorService(CommonService commonService) { public CommonErrorService(CommonService commonService) {
@@ -20,9 +20,9 @@ public class CommonErrorService {
ModelMap model, ModelMap model,
HttpServletResponse response, HttpServletResponse response,
HttpSession httpSession, HttpSession httpSession,
ErrorDetails errorDetails) { MessageDetails messageDetails) {
response.setStatus(errorDetails.getHttpStatus()); response.setStatus(messageDetails.getHttpStatus());
model.addAttribute(ERROR_ATTRIBUTE, errorDetails); model.addAttribute(ERROR_ATTRIBUTE, messageDetails);
Session session = commonService.getSession(httpSession); Session session = commonService.getSession(httpSession);
if (session != null) { if (session != null) {

View File

@@ -1,18 +1,27 @@
package matrix.services.edp; package matrix.services.edp;
import com.cygnus.db.CygnusDbExecutor;
import com.cygnus.db.SqlArrayParameter; import com.cygnus.db.SqlArrayParameter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.sql.SQLException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import lib.models.ApplicationDetails;
import lib.models.CasePunching; import lib.models.CasePunching;
import lib.models.ErrorDetails; import lib.models.ApplicationSaveResult;
import lib.models.MessageDetails;
import lib.models.Option; import lib.models.Option;
import lib.constants.ApplicationError; import lib.models.UserSession;
import lib.constants.ApplicationMessage;
import lib.exceptions.ApplicationException;
import matrix.nimble.edp.punching.model.PunchingHandler; import matrix.nimble.edp.punching.model.PunchingHandler;
import matrix.nimble.utilities.GlobalClass; import matrix.nimble.utilities.GlobalClass;
import matrix.services.commons.CommonService; import matrix.services.commons.CommonService;
@@ -35,11 +44,115 @@ public class PunchingService {
SqlArrayParameter.text("CATEGORY", "PRODUCT"); SqlArrayParameter.text("CATEGORY", "PRODUCT");
private final CommonService commonService; private final CommonService commonService;
private final CygnusDbExecutor dbExecutor;
private final ObjectMapper objectMapper;
public PunchingService(CommonService commonService) { public PunchingService(CommonService commonService) {
this.commonService = commonService; this.commonService = commonService;
this.dbExecutor = null;
this.objectMapper = null;
} }
@Autowired
public PunchingService(
CommonService commonService, CygnusDbExecutor dbExecutor, ObjectMapper objectMapper) {
this.commonService = commonService;
this.dbExecutor = dbExecutor;
this.objectMapper = objectMapper;
}
public ApplicationSaveResult saveApplication(ApplicationDetails details, UserSession session) {
if (dbExecutor == null || objectMapper == null) {
throw new IllegalStateException("Application-save dependencies are not configured");
}
validate(details, session);
sanitizeDynamicFields(details);
try {
String json = objectMapper.writeValueAsString(details);
ApplicationSaveResult result = dbExecutor.procedure("APPLICATION_SAVE",
new Object[] {json, session.getCompanyId(), session.getBranchId(),
session.getUserId()}, ApplicationSaveResult.class);
if (result == null || result.getApplicationId() == null) {
throw new ApplicationException(
ApplicationMessage.APPLICATION_SAVE_FAILED, "Database returned no application result");
}
return result;
} catch (JsonProcessingException exception) {
throw new ApplicationException(
ApplicationMessage.PAYLOAD_INVALID, "Unable to serialize application details", exception);
} catch (ApplicationException exception) {
throw exception;
} catch (RuntimeException exception) {
throw databaseFailure(exception);
}
}
private void validate(ApplicationDetails details, UserSession session) {
if (details == null || session == null || details.getPortfolioId() == null
|| details.getPortfolioId() <= 0) {
throw new ApplicationException(
ApplicationMessage.APPLICATION_VALIDATION_FAILED, "Portfolio is required");
}
if (details.getBankBranchId() == null || details.getBankBranchId() <= 0) {
throw new ApplicationException(
ApplicationMessage.APPLICATION_VALIDATION_FAILED, "Bank branch is required");
}
if (blank(details.getApplicationNumber()) || blank(details.getProduct())
|| blank(details.getCustomerName()) || blank(details.getApplicationType())) {
throw new ApplicationException(
ApplicationMessage.APPLICATION_VALIDATION_FAILED, "Required application fields are missing");
}
if (!Boolean.TRUE.equals(details.getResidenceVerification())
&& !Boolean.TRUE.equals(details.getResidenceTelephoneVerification())
&& !Boolean.TRUE.equals(details.getOfficeVerification())
&& !Boolean.TRUE.equals(details.getOfficeTelephoneVerification())
&& !Boolean.TRUE.equals(details.getPropertyVerification())
&& !Boolean.TRUE.equals(details.getReferenceVerification())
&& !Boolean.TRUE.equals(details.getDocumentVerification())) {
throw new ApplicationException(
ApplicationMessage.APPLICATION_VALIDATION_FAILED,
"At least one verification type is required");
}
}
private void sanitizeDynamicFields(ApplicationDetails details) {
if (details.getDynamicFields() == null || details.getDynamicFields().isEmpty()) {
details.setDynamicFields(new LinkedHashMap<>());
return;
}
Map<String, String> allowed = new LinkedHashMap<>();
details.getDynamicFields().forEach((name, value) -> {
if (name != null && (name.matches("field(?:[1-9]|1[0-2])")
|| "dtfield1".equals(name))) {
allowed.put(name, value == null ? null : value.trim());
}
});
details.setDynamicFields(allowed);
}
private ApplicationException databaseFailure(RuntimeException exception) {
Throwable cause = exception;
while (cause != null) {
if (cause instanceof SQLException sqlException) {
return switch (sqlException.getSQLState()) {
case "42501" -> new ApplicationException(
ApplicationMessage.APPLICATION_ACCESS_DENIED, sqlException.getMessage(), exception);
case "22023", "23502", "23503" -> new ApplicationException(
ApplicationMessage.APPLICATION_VALIDATION_FAILED, sqlException.getMessage(), exception);
case "23505" -> new ApplicationException(
ApplicationMessage.APPLICATION_SAVE_CONFLICT, sqlException.getMessage(), exception);
default -> new ApplicationException(
ApplicationMessage.APPLICATION_SAVE_FAILED, "Database save failed", exception);
};
}
cause = cause.getCause();
}
return new ApplicationException(
ApplicationMessage.APPLICATION_SAVE_FAILED, "Database save failed", exception);
}
private boolean blank(String value) { return value == null || value.isBlank(); }
public CasePunching init( public CasePunching init(
Short branchId, Short branchId,
Short userId, Short userId,
@@ -66,7 +179,7 @@ public class PunchingService {
return casePunching; return casePunching;
} }
public CasePunching addCase( public CasePunching prepareNewApplication(
CasePunching submitted, CasePunching submitted,
Short branchId, Short branchId,
Short companyId, Short companyId,
@@ -83,7 +196,7 @@ public class PunchingService {
result.setVisibleSections(List.of()); result.setVisibleSections(List.of());
result.setDynamicFields(""); result.setDynamicFields("");
result.setDynamicHtml(""); result.setDynamicHtml("");
result.setErrorDetails(new ErrorDetails(ApplicationError.BAD_REQUEST)); result.setMessageDetails(new MessageDetails(ApplicationMessage.BAD_REQUEST));
return result; return result;
} }
@@ -104,7 +217,7 @@ public class PunchingService {
result.setDynamicFields(""); result.setDynamicFields("");
result.setDynamicHtml(""); result.setDynamicHtml("");
result.setVisibleSections(List.of()); result.setVisibleSections(List.of());
result.setErrorDetails(new ErrorDetails(ApplicationError.INTERNAL_SERVER_ERROR)); result.setMessageDetails(new MessageDetails(ApplicationMessage.INTERNAL_SERVER_ERROR));
} }
result.setOptions(loadOptions(branchId, companyId, portfolioId)); result.setOptions(loadOptions(branchId, companyId, portfolioId));
@@ -126,7 +239,7 @@ public class PunchingService {
return freezeOptions(options); return freezeOptions(options);
} }
public CasePunching editCase( public CasePunching prepareApplicationEdit(
Short portfolioId, Short portfolioId,
String documentCaseId, String documentCaseId,
Short branchId, Short branchId,
@@ -135,7 +248,7 @@ public class PunchingService {
CasePunching submitted = new CasePunching(); CasePunching submitted = new CasePunching();
submitted.setPortfolioId(portfolioId); submitted.setPortfolioId(portfolioId);
submitted.setDocumentCaseId(documentCaseId); submitted.setDocumentCaseId(documentCaseId);
CasePunching result = addCase(submitted, branchId, companyId, userId); CasePunching result = prepareNewApplication(submitted, branchId, companyId, userId);
result.setFormMode(2); result.setFormMode(2);
result.setDocumentCaseId(documentCaseId); result.setDocumentCaseId(documentCaseId);
return result; return result;

View File

@@ -523,7 +523,8 @@ class RoleVisibleBatchMigrationTest {
String initialCase = Files.readString(punching.resolve("initcase.jsp")); String initialCase = Files.readString(punching.resolve("initcase.jsp"));
assertTrue(initialCase.contains("GotoRecord(this,'recno')")); assertTrue(initialCase.contains("GotoRecord(this,'recno')"));
assertTrue(initialCase.contains("ValidateSubmitForm('casedetails')")); assertTrue(initialCase.contains("CygnusInitiation.save(event)"));
assertTrue(initialCase.contains("/matrix/js/edp/initiation.js"));
assertTrue(initialCase.contains("AddNewRecord()")); assertTrue(initialCase.contains("AddNewRecord()"));
String initialDocs = Files.readString(punching.resolve("initdocs.jsp")); String initialDocs = Files.readString(punching.resolve("initdocs.jsp"));

View File

@@ -0,0 +1,66 @@
package matrix.nimble.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.UUID;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import lib.models.ApplicationDetails;
import lib.models.CaseSaveRequest;
import lib.exceptions.ApplicationException;
import org.junit.jupiter.api.Test;
class PayloadCryptoServiceTest {
@Test
void decryptsBrowserCompatibleEnvelopeAndRejectsReplay() throws Exception {
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
PayloadCryptoService service = new PayloadCryptoService(mapper, "", "");
ApplicationDetails details = new ApplicationDetails();
details.setApplicationId(42);
details.setApplicationNumber("APP-42");
String requestId = UUID.randomUUID().toString();
String timestamp = Instant.now().toString();
PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(
new X509EncodedKeySpec(Base64.getUrlDecoder().decode(service.encodedPublicKey())));
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey aesKey = generator.generateKey();
byte[] iv = new byte[12];
java.security.SecureRandom.getInstanceStrong().nextBytes(iv);
byte[] aad = String.join("|", service.keyId(), requestId, timestamp)
.getBytes(StandardCharsets.UTF_8);
Cipher payload = Cipher.getInstance("AES/GCM/NoPadding");
payload.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(128, iv));
payload.updateAAD(aad);
byte[] encryptedPayload = payload.doFinal(mapper.writeValueAsBytes(details));
Cipher wrapper = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
wrapper.init(Cipher.ENCRYPT_MODE, publicKey,
new OAEPParameterSpec("SHA-256", "MGF1",
MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT));
byte[] encryptedKey = wrapper.doFinal(aesKey.getEncoded());
Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
CaseSaveRequest request = new CaseSaveRequest(
service.keyId(), encoder.encodeToString(encryptedKey), encoder.encodeToString(iv),
encoder.encodeToString(encryptedPayload), requestId, timestamp);
assertEquals(42, service.decrypt(request, ApplicationDetails.class).getApplicationId());
assertThrows(ApplicationException.class, () -> service.decrypt(request, ApplicationDetails.class));
}
}

View File

@@ -3,8 +3,8 @@ package matrix.services.commons;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertSame;
import lib.models.ErrorDetails; import lib.models.MessageDetails;
import lib.constants.ApplicationError; import lib.constants.ApplicationMessage;
import matrix.nimble.model.Session; import matrix.nimble.model.Session;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
@@ -21,7 +21,7 @@ class CommonErrorServiceTest {
Session session = new Session(); Session session = new Session();
session.setMenuHtml("<div id='MenuBar'></div>"); session.setMenuHtml("<div id='MenuBar'></div>");
commonService.storeSession(httpSession, session); commonService.storeSession(httpSession, session);
ErrorDetails details = new ErrorDetails(ApplicationError.ACCESS_DENIED); MessageDetails details = new MessageDetails(ApplicationMessage.ACCESS_DENIED);
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
ModelMap model = new ModelMap(); ModelMap model = new ModelMap();

View File

@@ -15,6 +15,9 @@ public interface CygnusDbExecutor {
int update(int queryId, Object[] parameters); int update(int queryId, Object[] parameters);
int delete(int queryId, Object[] parameters); int delete(int queryId, Object[] parameters);
<T> T procedure(int queryId, Object[] parameters, Class<T> responseType); <T> T procedure(int queryId, Object[] parameters, Class<T> responseType);
default <T> T procedure(String queryKey, Object[] parameters, Class<T> responseType) {
throw new UnsupportedOperationException("Query keys are not supported by this executor");
}
<T> List<T> procedureRows(int queryId, Object[] parameters, Class<T> responseType); <T> List<T> procedureRows(int queryId, Object[] parameters, Class<T> responseType);
<T> void stream(int queryId, Object[] parameters, RowMapper<T> mapper, RowConsumer<T> consumer); <T> void stream(int queryId, Object[] parameters, RowMapper<T> mapper, RowConsumer<T> consumer);
<T> T transaction(TransactionCallback<T> callback); <T> T transaction(TransactionCallback<T> callback);

View File

@@ -109,6 +109,15 @@ public final class JdbcCygnusDbExecutor implements CygnusDbExecutor {
return rows.isEmpty() ? null : rows.getFirst(); return rows.isEmpty() ? null : rows.getFirst();
} }
@Override
public <T> T procedure(String queryKey, Object[] parameters, Class<T> responseType) {
QueryDefinition definition = queryProvider.get(queryKey);
if (definition.type() != QueryType.PROCEDURE) throw new QueryDefinitionException(
"Query " + queryKey + " is " + definition.type() + ", not " + QueryType.PROCEDURE);
DbResult<T> result = executeRowsOrCount(definition, parameters, responseType);
return result.rows().isEmpty() ? null : result.rows().getFirst();
}
@Override @Override
public <T> List<T> procedureRows(int queryId, Object[] parameters, Class<T> responseType) { public <T> List<T> procedureRows(int queryId, Object[] parameters, Class<T> responseType) {
DbResult<T> result = executeRowsOrCount( DbResult<T> result = executeRowsOrCount(

View File

@@ -3,4 +3,7 @@ package com.cygnus.db;
@FunctionalInterface @FunctionalInterface
public interface QueryDefinitionProvider { public interface QueryDefinitionProvider {
QueryDefinition get(int queryId); QueryDefinition get(int queryId);
default QueryDefinition get(String queryKey) {
throw new UnsupportedOperationException("Query keys are not supported by this provider");
}
} }

View File

@@ -0,0 +1,293 @@
-- Atomic application initiation save.
BEGIN;
CREATE OR REPLACE FUNCTION public.save_application_details(
p_application jsonb,
p_company_id smallint,
p_branch_id smallint,
p_user_id smallint
)
RETURNS TABLE(operation text, application_id integer, mv_code text, internal_uuid text)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $function$
DECLARE
d record;
v_case_id integer;
v_uuid text;
v_mvcode text;
v_operation text;
v_mvnumber integer;
v_mvprefix text;
v_batch text;
v_earlier smallint := 1;
v_tele_denied smallint := 0;
v_reference_denied smallint := 0;
v_now timestamp without time zone := clock_timestamp();
BEGIN
IF p_application IS NULL OR p_company_id IS NULL OR p_branch_id IS NULL OR p_user_id IS NULL THEN
RAISE EXCEPTION 'Incomplete case context' USING ERRCODE = '22023';
END IF;
SELECT * INTO d
FROM jsonb_to_record(p_application) AS x(
"applicationId" integer, "portfolioId" smallint, "bankBranchId" smallint,
"applicationNumber" text, "bankCode" text, "product" text, "loanAmount" text,
"customerName" text, "fatherName" text, "applicationType" text, "category" text,
"dateOfBirth" text, "contactPerson" text, "mobileNumber" text,
"specialInstruction" text, "residenceVerification" boolean,
"residenceTelephoneVerification" boolean, "officeVerification" boolean,
"officeTelephoneVerification" boolean, "propertyVerification" boolean,
"referenceVerification" boolean, "documentVerification" boolean,
"residenceCoApplicant" boolean, "residenceCoProprietor" boolean,
"officeCoProprietor" boolean, "sameResidenceAddress" boolean,
"sameOfficeAddress" boolean, "samePropertyAddress" boolean,
"residenceAddress1" text, "residenceAddress2" text, "residenceAddress3" text,
"residenceLandmark" text, "residenceColonyId" integer, "residenceCity" text,
"residencePincode" text, "residencePhone" text, "companyName" text,
"officeAddress1" text, "officeAddress2" text, "officeAddress3" text,
"officeLandmark" text, "officeColonyId" integer, "officeCity" text,
"officePincode" text, "department" text, "designation" text,
"officePhone" text, "extension" text, "propertyAddress1" text,
"propertyAddress2" text, "propertyAddress3" text, "propertyLandmark" text,
"propertyColonyId" integer, "propertyCity" text, "propertyPincode" text,
"referenceName1" text, "referenceAddress1" text, "referenceContactNumber1" text,
"referenceName2" text, "referenceAddress2" text, "referenceContactNumber2" text,
"autoCutOff" boolean, "formMode" integer
);
IF d."portfolioId" IS NULL OR d."portfolioId" <= 0
OR NOT EXISTS (
SELECT 1 FROM portfolio p
WHERE p.portfolio_id = d."portfolioId"
AND p.company_id = p_company_id
AND p.branch_id = p_branch_id
AND p.isactive = 1) THEN
RAISE EXCEPTION 'Portfolio is not available to this session' USING ERRCODE = '42501';
END IF;
IF coalesce(trim(d."applicationNumber"), '') = ''
OR coalesce(d."bankBranchId", 0) <= 0
OR coalesce(trim(d."product"), '') = ''
OR coalesce(trim(d."customerName"), '') = ''
OR coalesce(trim(d."applicationType"), '') = '' THEN
RAISE EXCEPTION 'Required case fields are missing' USING ERRCODE = '22023';
END IF;
IF NOT (coalesce(d."residenceVerification", false)
OR coalesce(d."residenceTelephoneVerification", false)
OR coalesce(d."officeVerification", false)
OR coalesce(d."officeTelephoneVerification", false)
OR coalesce(d."propertyVerification", false)
OR coalesce(d."referenceVerification", false)
OR coalesce(d."documentVerification", false)) THEN
RAISE EXCEPTION 'At least one verification is required' USING ERRCODE = '22023';
END IF;
v_case_id := coalesce(d."applicationId", 0);
IF v_case_id = 0 THEN
UPDATE mvcode
SET lastmvcode = lastmvcode + 1
WHERE portfolio_id = d."portfolioId"
RETURNING lastmvcode, trim(mvcprefix) INTO v_mvnumber, v_mvprefix;
IF NOT FOUND THEN
RAISE EXCEPTION 'MV code configuration is missing' USING ERRCODE = '23503';
END IF;
v_mvcode := coalesce(v_mvprefix, '') || v_mvnumber;
v_uuid := gen_random_uuid()::text;
INSERT INTO main (
portfolio_id, bank_branch_id, mvcode, applno, bankcode, product, loanamount,
customername, fathername, apptype, category, dob, contactperson, mobileno,
specialinst, rv, rtv, ov, otv, pv, refv, docv, rco, rcp, ocp, sradd, soadd,
spadd, raddr1, raddr2, raddr3, rlandmark, rcolony, rcity, rpincode, rphone,
companyname, oaddr1, oaddr2, oaddr3, olandmark, ocolony, ocity, opincode,
department, designation, ophone, extension, paddr1, paddr2, paddr3, plandmark,
pcolony, pcity, ppincode, refname1, refaddress1, refcontactno1, refname2,
refaddress2, refcontactno2, field1, field2, field3, field4, field5, field6,
field7, field8, field9, field10, field11, field12, dtfield1, receivedate,
addedby, addedon, company_id, branch_id, uuid)
VALUES (
d."portfolioId", coalesce(d."bankBranchId", 0), v_mvcode,
trim(d."applicationNumber"), nullif(trim(d."bankCode"), ''), trim(d."product"),
nullif(trim(d."loanAmount"), ''), trim(d."customerName"),
nullif(trim(d."fatherName"), ''), trim(d."applicationType"),
nullif(trim(d."category"), ''), nullif(trim(d."dateOfBirth"), ''),
nullif(trim(d."contactPerson"), ''), nullif(trim(d."mobileNumber"), ''),
coalesce(d."specialInstruction", ''),
coalesce(d."residenceVerification", false)::int,
coalesce(d."residenceTelephoneVerification", false)::int,
coalesce(d."officeVerification", false)::int,
coalesce(d."officeTelephoneVerification", false)::int,
coalesce(d."propertyVerification", false)::int,
coalesce(d."referenceVerification", false)::int,
coalesce(d."documentVerification", false)::int,
coalesce(d."residenceCoApplicant", false)::int,
coalesce(d."residenceCoProprietor", false)::int,
coalesce(d."officeCoProprietor", false)::int,
coalesce(d."sameResidenceAddress", false)::int,
coalesce(d."sameOfficeAddress", false)::int,
coalesce(d."samePropertyAddress", false)::int,
d."residenceAddress1", d."residenceAddress2", d."residenceAddress3",
d."residenceLandmark", coalesce(d."residenceColonyId", 0), d."residenceCity",
d."residencePincode", d."residencePhone", d."companyName", d."officeAddress1",
d."officeAddress2", d."officeAddress3", d."officeLandmark",
coalesce(d."officeColonyId", 0), d."officeCity", d."officePincode",
d."department", d."designation", d."officePhone", d."extension",
d."propertyAddress1", d."propertyAddress2", d."propertyAddress3",
d."propertyLandmark", coalesce(d."propertyColonyId", 0), d."propertyCity",
d."propertyPincode", d."referenceName1", d."referenceAddress1",
d."referenceContactNumber1", d."referenceName2", d."referenceAddress2",
d."referenceContactNumber2", p_application->'dynamicFields'->>'field1',
p_application->'dynamicFields'->>'field2', p_application->'dynamicFields'->>'field3',
p_application->'dynamicFields'->>'field4', p_application->'dynamicFields'->>'field5',
p_application->'dynamicFields'->>'field6', p_application->'dynamicFields'->>'field7',
p_application->'dynamicFields'->>'field8', p_application->'dynamicFields'->>'field9',
p_application->'dynamicFields'->>'field10', p_application->'dynamicFields'->>'field11',
p_application->'dynamicFields'->>'field12',
nullif(p_application->'dynamicFields'->>'dtfield1', '')::date,
current_date, p_user_id, v_now, p_company_id, p_branch_id, v_uuid)
RETURNING main.case_id INTO v_case_id;
INSERT INTO main_operations (uuid, company_id, branch_id, islocked, portfolio_id)
VALUES (v_uuid, p_company_id, p_branch_id, 0, d."portfolioId");
v_operation := 'INSERT';
ELSE
SELECT m.uuid, m.mvcode INTO v_uuid, v_mvcode
FROM main m
WHERE m.case_id = v_case_id
AND m.company_id = p_company_id
AND m.branch_id = p_branch_id
AND m.portfolio_id = d."portfolioId"
AND m.isdeleted = 0
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Case is not available to this session' USING ERRCODE = '42501';
END IF;
UPDATE main SET
bank_branch_id=coalesce(d."bankBranchId",0), applno=trim(d."applicationNumber"),
bankcode=nullif(trim(d."bankCode"),''), product=trim(d."product"),
loanamount=nullif(trim(d."loanAmount"),''), customername=trim(d."customerName"),
fathername=nullif(trim(d."fatherName"),''), apptype=trim(d."applicationType"),
category=nullif(trim(d."category"),''), dob=nullif(trim(d."dateOfBirth"),''),
contactperson=nullif(trim(d."contactPerson"),''), mobileno=nullif(trim(d."mobileNumber"),''),
specialinst=coalesce(d."specialInstruction",''),
rv=coalesce(d."residenceVerification",false)::int,
rtv=coalesce(d."residenceTelephoneVerification",false)::int,
ov=coalesce(d."officeVerification",false)::int,
otv=coalesce(d."officeTelephoneVerification",false)::int,
pv=coalesce(d."propertyVerification",false)::int,
refv=coalesce(d."referenceVerification",false)::int,
docv=coalesce(d."documentVerification",false)::int,
rco=coalesce(d."residenceCoApplicant",false)::int,
rcp=coalesce(d."residenceCoProprietor",false)::int,
ocp=coalesce(d."officeCoProprietor",false)::int,
sradd=coalesce(d."sameResidenceAddress",false)::int,
soadd=coalesce(d."sameOfficeAddress",false)::int,
spadd=coalesce(d."samePropertyAddress",false)::int,
raddr1=d."residenceAddress1", raddr2=d."residenceAddress2", raddr3=d."residenceAddress3",
rlandmark=d."residenceLandmark", rcolony=coalesce(d."residenceColonyId",0),
rcity=d."residenceCity", rpincode=d."residencePincode", rphone=d."residencePhone",
companyname=d."companyName", oaddr1=d."officeAddress1", oaddr2=d."officeAddress2",
oaddr3=d."officeAddress3", olandmark=d."officeLandmark",
ocolony=coalesce(d."officeColonyId",0), ocity=d."officeCity",
opincode=d."officePincode", department=d."department", designation=d."designation",
ophone=d."officePhone", extension=d."extension", paddr1=d."propertyAddress1",
paddr2=d."propertyAddress2", paddr3=d."propertyAddress3",
plandmark=d."propertyLandmark", pcolony=coalesce(d."propertyColonyId",0),
pcity=d."propertyCity", ppincode=d."propertyPincode", refname1=d."referenceName1",
refaddress1=d."referenceAddress1", refcontactno1=d."referenceContactNumber1",
refname2=d."referenceName2", refaddress2=d."referenceAddress2",
refcontactno2=d."referenceContactNumber2", field1=p_application->'dynamicFields'->>'field1',
field2=p_application->'dynamicFields'->>'field2', field3=p_application->'dynamicFields'->>'field3',
field4=p_application->'dynamicFields'->>'field4', field5=p_application->'dynamicFields'->>'field5',
field6=p_application->'dynamicFields'->>'field6', field7=p_application->'dynamicFields'->>'field7',
field8=p_application->'dynamicFields'->>'field8', field9=p_application->'dynamicFields'->>'field9',
field10=p_application->'dynamicFields'->>'field10', field11=p_application->'dynamicFields'->>'field11',
field12=p_application->'dynamicFields'->>'field12',
dtfield1=nullif(p_application->'dynamicFields'->>'dtfield1','')::date,
addedon=CASE WHEN addedby=0 THEN v_now ELSE addedon END,
addedby=CASE WHEN addedby=0 THEN p_user_id ELSE addedby END,
lasteditedon=v_now, lasteditedby=p_user_id
WHERE main.case_id=v_case_id;
IF coalesce(d."formMode", 0) = 2 THEN
INSERT INTO caseedit_history(user_id, operation, uuid, operationon)
VALUES (p_user_id, 'CASEEDIT', v_uuid, v_now);
UPDATE temp_reporting SET
bank_branch_id=coalesce(d."bankBranchId",0),
applno=trim(d."applicationNumber"), product=trim(d."product"),
customername=trim(d."customerName"), apptype=trim(d."applicationType"),
category=nullif(trim(d."category"),''),
rv=coalesce(d."residenceVerification",false)::int,
rtv=coalesce(d."residenceTelephoneVerification",false)::int,
ov=coalesce(d."officeVerification",false)::int,
otv=coalesce(d."officeTelephoneVerification",false)::int,
pv=coalesce(d."propertyVerification",false)::int,
refv=coalesce(d."referenceVerification",false)::int,
docv=coalesce(d."documentVerification",false)::int,
rco=coalesce(d."residenceCoApplicant",false)::int,
rcp=coalesce(d."residenceCoProprietor",false)::int,
ocp=coalesce(d."officeCoProprietor",false)::int,
sradd=coalesce(d."sameResidenceAddress",false)::int,
soadd=coalesce(d."sameOfficeAddress",false)::int,
spadd=coalesce(d."samePropertyAddress",false)::int
WHERE uuid=v_uuid;
IF coalesce(d."residenceVerification", false) THEN
INSERT INTO residence(uuid)
SELECT v_uuid WHERE NOT EXISTS (SELECT 1 FROM residence WHERE uuid=v_uuid);
END IF;
IF coalesce(d."officeVerification", false) THEN
INSERT INTO office(uuid)
SELECT v_uuid WHERE NOT EXISTS (SELECT 1 FROM office WHERE uuid=v_uuid);
END IF;
IF coalesce(d."residenceTelephoneVerification", false)
OR coalesce(d."officeTelephoneVerification", false) THEN
SELECT coalesce(max(isdenied), 0) INTO v_tele_denied
FROM denied_process
WHERE portfolio_id=d."portfolioId" AND process_id=14 AND isdenied=1;
IF v_tele_denied=0 THEN
INSERT INTO telecalling(uuid)
SELECT v_uuid WHERE NOT EXISTS (SELECT 1 FROM telecalling WHERE uuid=v_uuid);
ELSE
UPDATE temp_reporting SET rtv=0, otv=0 WHERE uuid=v_uuid;
END IF;
END IF;
IF coalesce(d."referenceVerification", false) THEN
SELECT coalesce(max(isdenied), 0) INTO v_reference_denied
FROM denied_process
WHERE portfolio_id=d."portfolioId" AND process_id=15 AND isdenied=1;
IF v_reference_denied=0 THEN
INSERT INTO refrencecalling(uuid)
SELECT v_uuid WHERE NOT EXISTS (SELECT 1 FROM refrencecalling WHERE uuid=v_uuid);
ELSE
UPDATE temp_reporting SET refv=0 WHERE uuid=v_uuid;
END IF;
END IF;
IF coalesce(d."propertyVerification", false) THEN
INSERT INTO property(uuid)
SELECT v_uuid WHERE NOT EXISTS (SELECT 1 FROM property WHERE uuid=v_uuid);
END IF;
END IF;
v_operation := 'UPDATE';
END IF;
IF coalesce(d."autoCutOff", false) THEN
v_batch := CASE WHEN localtime <= time '14:30' THEN 'AM' ELSE 'PM' END;
IF d."portfolioId" IN (68,129) THEN v_earlier := -2; END IF;
UPDATE main_operations SET allocation=1, sms=1, telesheet=-2,
earlier=v_earlier, negative=v_earlier, cutoffby=p_user_id,
islocked=p_user_id, batch=v_batch, cutoffon=v_now
WHERE (cutoffby IS NULL OR cutoffby=0) AND uuid=v_uuid;
END IF;
RETURN QUERY SELECT v_operation, v_case_id, v_mvcode, v_uuid;
END;
$function$;
REVOKE ALL ON FUNCTION public.save_application_details(jsonb,smallint,smallint,smallint) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.save_application_details(jsonb,smallint,smallint,smallint) TO postgres;
COMMIT;