diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java index 1e824b59..3c695cb7 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java @@ -529,10 +529,16 @@ public class QValueFormatter } } - ///////////////////////////////////////////// - // if field type is blob, update its value // - ///////////////////////////////////////////// - if(QFieldType.BLOB.equals(field.getType())) + //////////////////////////////////////////////////////////////////////////////////////////////// + // if field type is blob OR if there's a supplemental process or code-ref that needs to run - // + // then update its value to be a callback-url that'll give access to the bytes to download // + // implied here is that a String value (w/o supplemental code/proc) has its value stay as a // + // URL, which is where the file is directly downloaded from. And in the case of a String // + // with code-to-run, then the code should run, followed by a redirect to the value URL. // + //////////////////////////////////////////////////////////////////////////////////////////////// + if(QFieldType.BLOB.equals(field.getType()) + || adornmentValues.containsKey(AdornmentType.FileDownloadValues.SUPPLEMENTAL_CODE_REFERENCE) + || adornmentValues.containsKey(AdornmentType.FileDownloadValues.SUPPLEMENTAL_PROCESS_NAME)) { record.setValue(field.getName(), "/data/" + table.getName() + "/" + primaryKey + "/" + field.getName() + "/" + fileName); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/AdornmentType.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/AdornmentType.java index ba78ceef..7e3115e5 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/AdornmentType.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/fields/AdornmentType.java @@ -68,6 +68,9 @@ public enum AdornmentType String DEFAULT_EXTENSION = "defaultExtension"; String DEFAULT_MIME_TYPE = "defaultMimeType"; + String SUPPLEMENTAL_PROCESS_NAME = "supplementalProcessName"; + String SUPPLEMENTAL_CODE_REFERENCE = "supplementalCodeReference"; + //////////////////////////////////////////////////// // use these two together, as in: // // FILE_NAME_FORMAT = "Order %s Packing Slip.pdf" // diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java index 1b2094be..c82274d6 100644 --- a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementation.java @@ -44,6 +44,7 @@ import java.util.Optional; import java.util.function.Supplier; import com.fasterxml.jackson.core.type.TypeReference; import com.kingsrook.qqq.backend.core.actions.async.AsyncJobManager; +import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction; import com.kingsrook.qqq.backend.core.actions.metadata.ProcessMetaDataAction; @@ -51,6 +52,8 @@ import com.kingsrook.qqq.backend.core.actions.metadata.TableMetaDataAction; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.reporting.ExportAction; import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; @@ -77,6 +80,7 @@ import com.kingsrook.qqq.backend.core.model.actions.metadata.ProcessMetaDataInpu import com.kingsrook.qqq.backend.core.model.actions.metadata.ProcessMetaDataOutput; import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput; import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput; import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportOutput; import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportDestination; @@ -106,6 +110,7 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; @@ -130,6 +135,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils; import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder; import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeConsumer; import com.kingsrook.qqq.backend.core.utils.lambdas.UnsafeFunction; +import com.kingsrook.qqq.middleware.javalin.misc.DownloadFileSupplementalAction; import io.javalin.Javalin; import io.javalin.apibuilder.EndpointGroup; import io.javalin.http.Context; @@ -1089,12 +1095,13 @@ public class QJavalinImplementation throw (new QNotFoundException("Could not find " + table.getLabel() + " with " + table.getFields().get(table.getPrimaryKeyField()).getLabel() + " of " + primaryKey)); } - String mimeType = null; - Optional fileDownloadAdornment = fieldMetaData.getAdornments().stream().filter(a -> a.getType().equals(AdornmentType.FILE_DOWNLOAD)).findFirst(); + String mimeType = null; + Optional fileDownloadAdornment = fieldMetaData.getAdornments().stream().filter(a -> a.getType().equals(AdornmentType.FILE_DOWNLOAD)).findFirst(); + Map adornmentValues = null; if(fileDownloadAdornment.isPresent()) { - Map values = fileDownloadAdornment.get().getValues(); - mimeType = ValueUtils.getValueAsString(values.get(AdornmentType.FileDownloadValues.DEFAULT_MIME_TYPE)); + adornmentValues = fileDownloadAdornment.get().getValues(); + mimeType = ValueUtils.getValueAsString(adornmentValues.get(AdornmentType.FileDownloadValues.DEFAULT_MIME_TYPE)); } if(mimeType != null) @@ -1107,7 +1114,56 @@ public class QJavalinImplementation context.header("Content-Disposition", "attachment; filename=" + filename); } - context.result(getOutput.getRecord().getValueByteArray(fieldName)); + ////////////////////////////////////////////////////////////////////////////////////////////// + // if the adornment has a supplemental process name in it, or a supplemental code reference // + // then execute that custom code - e.g., to log that the file was downloaded. // + ////////////////////////////////////////////////////////////////////////////////////////////// + if(fileDownloadAdornment.isPresent()) + { + String processName = ValueUtils.getValueAsString(adornmentValues.get(AdornmentType.FileDownloadValues.SUPPLEMENTAL_PROCESS_NAME)); + if(StringUtils.hasContent(processName)) + { + RunProcessInput input = new RunProcessInput(); + input.setProcessName(processName); + input.setCallback(QProcessCallbackFactory.forRecord(getOutput.getRecord())); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + input.addValue("tableName", tableName); + input.addValue("primaryKey", primaryKey); + input.addValue("fieldName", fieldName); + input.addValue("filename", filename); + new RunProcessAction().execute(input); + } + else if(adornmentValues.containsKey(AdornmentType.FileDownloadValues.SUPPLEMENTAL_CODE_REFERENCE)) + { + QCodeReference codeReference = (QCodeReference) adornmentValues.get(AdornmentType.FileDownloadValues.SUPPLEMENTAL_CODE_REFERENCE); + + DownloadFileSupplementalAction action = QCodeLoader.getAdHoc(DownloadFileSupplementalAction.class, codeReference); + + DownloadFileSupplementalAction.DownloadFileSupplementalActionInput input = new DownloadFileSupplementalAction.DownloadFileSupplementalActionInput() + .withTableName(tableName) + .withFieldName(fieldName) + .withPrimaryKey(primaryKey) + .withFileName(filename); + + DownloadFileSupplementalAction.DownloadFileSupplementalActionOutput output = new DownloadFileSupplementalAction.DownloadFileSupplementalActionOutput(); + action.run(input, output); + } + } + + ///////////////////////////////////////////////////////// + // if the field is a BLOB - send the bytes to the user // + ///////////////////////////////////////////////////////// + if(QFieldType.BLOB.equals(fieldMetaData.getType())) + { + context.result(getOutput.getRecord().getValueByteArray(fieldName)); + } + else + { + ////////////////////////////////////////////////////////////////// + // else - assume a string is a URL - and issue a redirect to it // + ////////////////////////////////////////////////////////////////// + context.redirect(getOutput.getRecord().getValueString(fieldName)); + } QJavalinAccessLogger.logEndSuccess(); } diff --git a/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/misc/DownloadFileSupplementalAction.java b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/misc/DownloadFileSupplementalAction.java new file mode 100644 index 00000000..3ab31747 --- /dev/null +++ b/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/misc/DownloadFileSupplementalAction.java @@ -0,0 +1,198 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.middleware.javalin.misc; + + +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** custom code that can run when user downloads a file. Set as a code-reference + ** on a field adornment. + *******************************************************************************/ +public interface DownloadFileSupplementalAction +{ + + /*************************************************************************** + ** + ***************************************************************************/ + void run(DownloadFileSupplementalActionInput input, DownloadFileSupplementalActionOutput output) throws QException; + + + /*************************************************************************** + ** + ***************************************************************************/ + class DownloadFileSupplementalActionInput + { + private String tableName; + private String primaryKey; + private String fieldName; + private String fileName; + + + + /******************************************************************************* + ** Getter for tableName + ** + *******************************************************************************/ + public String getTableName() + { + return tableName; + } + + + + /******************************************************************************* + ** Setter for tableName + ** + *******************************************************************************/ + public void setTableName(String tableName) + { + this.tableName = tableName; + } + + + + /******************************************************************************* + ** Fluent setter for tableName + ** + *******************************************************************************/ + public DownloadFileSupplementalActionInput withTableName(String tableName) + { + this.tableName = tableName; + return (this); + } + + + + /******************************************************************************* + ** Getter for primaryKey + ** + *******************************************************************************/ + public String getPrimaryKey() + { + return primaryKey; + } + + + + /******************************************************************************* + ** Setter for primaryKey + ** + *******************************************************************************/ + public void setPrimaryKey(String primaryKey) + { + this.primaryKey = primaryKey; + } + + + + /******************************************************************************* + ** Fluent setter for primaryKey + ** + *******************************************************************************/ + public DownloadFileSupplementalActionInput withPrimaryKey(String primaryKey) + { + this.primaryKey = primaryKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for fieldName + ** + *******************************************************************************/ + public String getFieldName() + { + return fieldName; + } + + + + /******************************************************************************* + ** Setter for fieldName + ** + *******************************************************************************/ + public void setFieldName(String fieldName) + { + this.fieldName = fieldName; + } + + + + /******************************************************************************* + ** Fluent setter for fieldName + ** + *******************************************************************************/ + public DownloadFileSupplementalActionInput withFieldName(String fieldName) + { + this.fieldName = fieldName; + return (this); + } + + + + /******************************************************************************* + ** Getter for fileName + ** + *******************************************************************************/ + public String getFileName() + { + return fileName; + } + + + + /******************************************************************************* + ** Setter for fileName + ** + *******************************************************************************/ + public void setFileName(String fileName) + { + this.fileName = fileName; + } + + + + /******************************************************************************* + ** Fluent setter for fileName + ** + *******************************************************************************/ + public DownloadFileSupplementalActionInput withFileName(String fileName) + { + this.fileName = fileName; + return (this); + } + + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + class DownloadFileSupplementalActionOutput + { + + } +} diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java index df633b30..6d01c93d 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/QJavalinImplementationTest.java @@ -29,8 +29,11 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; +import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; import com.kingsrook.qqq.backend.core.logging.QCollectingLogger; @@ -39,12 +42,19 @@ import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat; import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceLambda; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.modules.backend.implementations.mock.MockBackendModule; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.SleepUtils; +import com.kingsrook.qqq.middleware.javalin.misc.DownloadFileSupplementalAction; import kong.unirest.HttpResponse; import kong.unirest.Unirest; import org.apache.logging.log4j.Level; @@ -282,6 +292,69 @@ class QJavalinImplementationTest extends QJavalinTestBase + /******************************************************************************* + ** test downloading from a URL field + ** + *******************************************************************************/ + @Test + public void test_dataDownloadRecordFieldUrl() + { + try + { + TestDownloadFileSupplementalAction.callCount = 0; + + Unirest.config().followRedirects(false); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // first request - has no custom code - should just give us back a redirect to the value in the field // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + HttpResponse response = Unirest.get(BASE_URL + "/data/person/1/licenseScanPdfUrl/License-1.pdf").asString(); + assertEquals(302, response.getStatus()); + assertThat(response.getHeaders().get("location").get(0)).contains("https://"); + + //////////////////////////////////////////////////// + // set a code-reference on the download adornment // + //////////////////////////////////////////////////// + Optional fileDownloadAdornment = QJavalinImplementation.getQInstance().getTable(TestUtils.TABLE_NAME_PERSON) + .getField("licenseScanPdfUrl") + .getAdornment(AdornmentType.FILE_DOWNLOAD); + fileDownloadAdornment.get().withValue(AdornmentType.FileDownloadValues.SUPPLEMENTAL_CODE_REFERENCE, new QCodeReference(TestDownloadFileSupplementalAction.class)); + + ///////////////////////////////////////// + // request again - assert the code ran // + ///////////////////////////////////////// + assertEquals(0, TestDownloadFileSupplementalAction.callCount); + response = Unirest.get(BASE_URL + "/data/person/1/licenseScanPdfUrl/License-1.pdf").asString(); + assertEquals(302, response.getStatus()); + assertThat(response.getHeaders().get("location").get(0)).contains("https://"); + assertEquals(1, TestDownloadFileSupplementalAction.callCount); + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // set adornment to run process (note, leaving the code-ref - this demonstrates that process "trumps" if both exist) // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + AtomicInteger processRunCount = new AtomicInteger(0); + QJavalinImplementation.getQInstance().addProcess(new QProcessMetaData().withName("testDownloadProcess").withStep( + new QBackendStepMetaData().withName("execute").withCode(new QCodeReferenceLambda((input, output) -> processRunCount.incrementAndGet())) + )); + fileDownloadAdornment.get().withValue(AdornmentType.FileDownloadValues.SUPPLEMENTAL_PROCESS_NAME, "testDownloadProcess"); + + ///////////////////////////////////////// + // request again - assert the code ran // + ///////////////////////////////////////// + response = Unirest.get(BASE_URL + "/data/person/1/licenseScanPdfUrl/License-1.pdf").asString(); + assertEquals(302, response.getStatus()); + assertThat(response.getHeaders().get("location").get(0)).contains("https://"); + assertEquals(1, TestDownloadFileSupplementalAction.callCount); + assertEquals(1, processRunCount.get()); + } + finally + { + Unirest.config().reset(); + } + } + + + /******************************************************************************* ** test a table get (single record) for an id that isn't found ** @@ -1395,4 +1468,19 @@ class QJavalinImplementationTest extends QJavalinTestBase } } + + + /*************************************************************************** + ** + ***************************************************************************/ + public static class TestDownloadFileSupplementalAction implements DownloadFileSupplementalAction + { + static int callCount = 0; + + @Override + public void run(DownloadFileSupplementalActionInput input, DownloadFileSupplementalActionOutput output) throws QException + { + callCount++; + } + } } diff --git a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java index 32b6ec2d..6773f4fe 100644 --- a/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java +++ b/qqq-middleware-javalin/src/test/java/com/kingsrook/qqq/backend/javalin/TestUtils.java @@ -25,6 +25,7 @@ package com.kingsrook.qqq.backend.javalin; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.sql.Connection; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Objects; @@ -316,6 +317,8 @@ public class TestUtils .withField(new QFieldMetaData("testScriptId", QFieldType.INTEGER).withBackendName("test_script_id")) .withField(new QFieldMetaData("photo", QFieldType.BLOB).withBackendName("photo")) .withField(new QFieldMetaData("photoFileName", QFieldType.STRING).withBackendName("photo_file_name")) + .withField(new QFieldMetaData("licenseScanPdfUrl", QFieldType.STRING).withBackendName("license_scan_pdf_url")) + .withAssociation(new Association().withName("pets").withJoinName("personJoinPet").withAssociatedTableName(TABLE_NAME_PET)) .withAssociatedScript(new AssociatedScript() .withFieldName("testScriptId") @@ -331,6 +334,11 @@ public class TestUtils .withValue(AdornmentType.FileDownloadValues.DEFAULT_MIME_TYPE, "image") .withValue(AdornmentType.FileDownloadValues.FILE_NAME_FIELD, "photoFileName")); + qTableMetaData.getField("licenseScanPdfUrl") + .withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD) + .withValue(AdornmentType.FileDownloadValues.FILE_NAME_FORMAT, "License-%s.pdf") + .withValue(AdornmentType.FileDownloadValues.FILE_NAME_FORMAT_FIELDS, new ArrayList<>(List.of("id")))); + return (qTableMetaData); } diff --git a/qqq-middleware-javalin/src/test/resources/prime-test-database.sql b/qqq-middleware-javalin/src/test/resources/prime-test-database.sql index 06e306e0..adb70cc5 100644 --- a/qqq-middleware-javalin/src/test/resources/prime-test-database.sql +++ b/qqq-middleware-javalin/src/test/resources/prime-test-database.sql @@ -33,10 +33,11 @@ CREATE TABLE person partner_person_id INT, test_script_id INT, photo BLOB, - photo_file_name VARCHAR(50) + photo_file_name VARCHAR(50), + license_scan_pdf_url VARCHAR(250) ); -INSERT INTO person (id, first_name, last_name, birth_date, email, partner_person_id, photo, photo_file_name) VALUES (1, 'Darin', 'Kelkhoff', '1980-05-31', 'darin.kelkhoff@gmail.com', 6, '12345', 'darin-photo.png'); +INSERT INTO person (id, first_name, last_name, birth_date, email, partner_person_id, photo, photo_file_name, license_scan_pdf_url) VALUES (1, 'Darin', 'Kelkhoff', '1980-05-31', 'darin.kelkhoff@gmail.com', 6, '12345', 'darin-photo.png', 'https://somedomain/somepath.pdf'); INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (2, 'James', 'Maes', '1980-05-15', 'jmaes@mmltholdings.com'); INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (3, 'Tim', 'Chamberlain', '1976-05-28', 'tchamberlain@mmltholdings.com'); INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (4, 'Tyler', 'Samples', '1990-01-01', 'tsamples@mmltholdings.com');