Exemple #1
0
        public async Task <IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
            [Queue("error-notification")] IAsyncCollector <string> errors,
            ILogger log)
        {
            log.LogInformation("RetrieveContactByEmailAddr HTTP trigger");

            // the email address is expected to come in as a queryparam.
            string email = req.Query["email"];

            // Instantiate our convenient wrapper for the error-log queue
            var errQ = new ErrorQueueLogger(errors, "CrmUpdateHandler", nameof(RetrieveContactByEmailAddr));


            // Retrieve the Hubspot contact corresponding to this email address
            try
            {
                var contactResult = await this._hubSpotAdapter.RetrieveHubspotContactByEmailAddr(email, fetchPreviousValues : false, log : log, isTest : false);

                if (contactResult.StatusCode == HttpStatusCode.OK)
                {
                    return(new OkObjectResult(contactResult.Payload));
                }
                else if (contactResult.StatusCode == HttpStatusCode.NotFound)
                {
                    return((ActionResult) new NotFoundResult());
                }
                else
                {
                    log.LogError($"Error: HTTP {contactResult.StatusCode} {contactResult.ErrorMessage} for '{email}'");
                    errQ.LogError($"Error: HTTP {contactResult.StatusCode} {contactResult.ErrorMessage} for '{email}'");
                    return(new StatusCodeResult((int)contactResult.StatusCode));
                }
            }
            catch (Exception ex)
            {
                log.LogError($"Exception: {ex.Message} retrieving contact '{email}'");
                errQ.LogError($"Exception: {ex.Message} retrieving contact '{email}'");
                return(new StatusCodeResult(500));
            }
        }
Exemple #2
0
        public async Task <IActionResult> CreateNewContact(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            [Queue("error-notification")] IAsyncCollector <string> errors,
            [Queue("existing-contact-update-review")] IAsyncCollector <string> updateReviewQueue,
            [Queue("installations-to-be-created")] IAsyncCollector <string> installationsAwaitingCreationQueue,
            ILogger log)
        {
            log.LogInformation("CreateNewCrmContact triggered");

            // Test mode can be turned on by passing ?test, or by running from localhost
            // It will use the Hubspot sandbox via a hapikey override, and return a Contact with a hard-coded crmid=2001
            var test   = req.Query["test"];
            var isTest = test.Count > 0;

            if (req.Host.Host == "localhost")
            {
                isTest = true;
            }

            // Instantiate our convenient wrapper for the error-log queue
            var errQ = new ErrorQueueLogger(errors, "CrmUpdateHandler", nameof(CrmContactCreator));

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();

            if (string.IsNullOrEmpty(requestBody))
            {
                errQ.LogError("Request body is not empty");
                return(new BadRequestObjectResult("Empty Request Body"));
            }

            // For a while, it will be handy to keep a record of the original JSON in the log files, in
            // case we need to track and fix systemic failures that lay undetected for a while.
            log.LogInformation(requestBody);


            dynamic userdata = JsonConvert.DeserializeObject(requestBody);

            // Test
            //userdata.contact.crmid = 99;
            //var dbg = new StringContent(userdata.ToString());


            // If body is empty or not JSON, just return
            if (userdata == null)
            {
                log.LogWarning("Contact information was empty or not JSON");
                errQ.LogError("Contact information was empty or not JSON");
                return(new OkResult());
            }

            string leadStatus = userdata?.leadStatus;

            string firstname     = userdata?.contact?.firstname;
            string lastname      = userdata?.contact?.lastname;
            string preferredName = userdata?.contact?.firstname;   // initialise the preferred name as the first name
            string email         = userdata?.contact?.email;
            string phone         = userdata?.contact?.phone;

            if (string.IsNullOrEmpty(phone))
            {
                phone = userdata?.contact?.mobilephone;
            }

            string installStreetAddress1 = userdata?.installAddress?.street;
            string installStreetAddress2 = userdata?.installAddress?.unit;
            string installCity           = userdata?.installAddress?.suburb;
            string installState          = userdata?.installAddress?.state;
            string installPostcode       = userdata?.installAddress?.postcode;

            string propertyOwnership = userdata?.property?.propertyOwnership; // "owner" or
            string propertyType      = userdata?.property?.propertyType;      // "business" or
            string abn = userdata?.property?.abn;

            string customerStreetAddress1 = userdata?.signatories?.signer1?.address?.street;
            string customerStreetAddress2 = userdata?.signatories?.signer1?.address?.unit;
            string customerCity           = userdata?.signatories?.signer1?.address?.suburb;
            string customerState          = userdata?.signatories?.signer1?.address?.state;
            string customerPostcode       = userdata?.signatories?.signer1?.address?.postcode;


            string mortgageStatus = userdata?.mortgage?.mortgageStatus; // "yes" / "no"
            string bankName       = userdata?.mortgage?.bankName;       // user-selected bank from the dropdown. Drawn from a Blob which in turns is generated by the Flow called "When BankContactDetails change => update bank-dropdown JSON"
            string bankOther      = userdata?.mortgage?.bankOther;      // if user selected "Other" from the dropdown, this is what they entered
            //string bankBranch = userdata?.mortgage?.bankBranch;   // we don't collect bank branch. That comes from BankContactDetails

            const bool installationRecordExists = true; // inhibit the creation of an Installation record.

            log.LogInformation($"Creating {firstname} {lastname} as {email} {(isTest ? "in test database" : string.Empty)}");

            var crmAccessResult = await this._hubSpotAdapter.CreateHubspotContactAsync(
                email,
                firstname,
                lastname,
                preferredName,
                phone,
                customerStreetAddress1,
                customerStreetAddress2,
                customerCity,
                customerState,
                customerPostcode,
                leadStatus,
                installationRecordExists,
                log,
                isTest);

            // Some failures aren't really failures
            if (crmAccessResult.StatusCode == System.Net.HttpStatusCode.Conflict)
            {
                // A Conflict is not unexpected. However, we can't blindly overwrite existing contact details when we don't know anything
                // about the intentions of the caller. The changes must be Approved by a human.
                // To facilitate the Approval process, we take the original data packet (which has enough information for both a HubSpot
                // contact and an Installation record) and supplement it with a list of changes, which the Approval flow can use to present
                // a nice(ish) UI to the approver. After approval, the packet can then continue to flow (via queues) to process that create
                // installations and effect changes to hubspot contacts

                var orig = crmAccessResult.Payload;
                //var changeList = new List<UpdateReviewChange>();

                // The changelist must serialise to 'nice' JSON. No nulls, else Flow can't parse it.
                var updateReview = new UpdateReview();
                updateReview.AddChange("First", orig.firstName ?? "", firstname ?? "");
                updateReview.AddChange("Last", orig.lastName ?? "", lastname ?? "");
                updateReview.AddChange("Phone", orig.phone ?? "", phone ?? "");
                updateReview.AddChange("Street Address", orig.streetAddress ?? "", (customerStreetAddress1 + " " + customerStreetAddress2).Trim());
                updateReview.AddChange("City", orig.city ?? "", customerCity ?? "");
                updateReview.AddChange("State", orig.state ?? "", customerState ?? "");
                updateReview.AddChange("Postcode", orig.postcode ?? "", customerPostcode ?? "");

                var newLeadStatus = HubspotAdapter.ResolveLeadStatus(leadStatus);
                updateReview.AddChange("Lead status", orig.leadStatus ?? "", newLeadStatus ?? "");

                // Mimic the installation-inhib logic in the original contact-creation code.
                if (newLeadStatus == "READY_TO_ENGAGE")
                {
                    if (orig.leadStatus == "INTERESTED")
                    {
                        // We need to inhibit the creation of an Installation record if this approval goes ahead, to prevent a race condition
                        updateReview.AddChange("installationrecordexists", "", "true");
                    }
                }

                // TODO: Call an installation-details web-service to get these details

                var installAddress = HubspotAdapter.AssembleCustomerAddress(
                    (installStreetAddress1 + " " + installStreetAddress2).Trim(),
                    installCity,
                    installState,
                    installPostcode);

                // TODO: more...including Installation fields...
                updateReview.AddChange("Install Address", "", installAddress ?? "");      // TODO
                updateReview.AddChange("propertyOwnership", "", propertyOwnership ?? ""); // TODO
                updateReview.AddChange("propertyType", "", propertyType ?? "");           // TODO
                updateReview.AddChange("ABN", "", abn ?? "");                             // TODO

                updateReview.AddChange("mortgageStatus", "", mortgageStatus ?? "");       // TODO

                var newBankName = (bankName == "Other") ? bankOther : bankName;
                updateReview.AddChange("bankName", "", newBankName ?? "");  // TODO

                userdata.changes = Newtonsoft.Json.Linq.JToken.FromObject(updateReview.Changes);

                // Prepare the original 'Join' data packet for re-use as an Installation if a
                // human approves the changes. And set an 'updatePermitted' flag that signals
                // to the receiving process that the data has been through a human review, and
                // it's OK to update the Installation if it exists already.
                userdata.contact.crmid   = crmAccessResult.Payload.contactId;
                userdata.sendContract    = true;
                userdata.updatePermitted = true;    // if it passes human approval, then it's OK to update the Installation

                string updateReviewPackage = JsonConvert.SerializeObject(userdata);
                log.LogInformation("For the 'existing-contact-update-review' queue:\n" + updateReviewPackage);

                // Queue the submission for human approval
                await updateReviewQueue.AddAsync(updateReviewPackage);

                // Control now passes to the Flow called on contact-update-review message, where the changes are approved or rejected
                return((ActionResult) new OkResult());
            }
            else if (crmAccessResult.StatusCode != System.Net.HttpStatusCode.OK)
            {
                // This is a real failure. We cannot continue.
                log.LogError($"Error {crmAccessResult.StatusCode} creating HubSpot contact: {crmAccessResult.ErrorMessage}");
                errQ.LogError("Error " + crmAccessResult.StatusCode + " creating HubSpot contact: " + crmAccessResult.ErrorMessage);
                return(new BadRequestObjectResult(crmAccessResult.ErrorMessage));
            }

            log.LogInformation($"{firstname} {lastname} ({email}) created as {crmAccessResult.Payload.contactId}");


            // Now we must create an Installations record by placing a job on the 'installations-to-be-created' queue
            // The structure we place on this queue is the same structure as we receive in this method, with the addition of
            //      (1) the contract.crmid property
            //      (2) a 'sendContract' flag that tells the Installation-creator to send a contract when the Installation record is created
            // NB: 'updatePermitted' is NOT set, because it would be a big surprise worthy of a big error to find an existing
            // Installation in the absence of a HubSpot contact record
            userdata.contact.crmid = crmAccessResult.Payload.contactId;
            userdata.sendContract  = true;

            // Place the augmented join-up data-packet on the queue. It will be picked up by the 'DequeueInstallationsForCreation' function in the
            // PlicoInstallationsHandler solution.
            var msg = JsonConvert.SerializeObject(userdata);
            await installationsAwaitingCreationQueue.AddAsync(msg);

            return((ActionResult) new OkObjectResult(crmAccessResult.Payload));
        }
        public async Task Run(
            [QueueTrigger("raw-hubspot-change-notifications")] string requestBody,
            [Queue("error-notification")] IAsyncCollector <string> errors,
            ILogger log)
        {
            log.LogInformation($"DequeueAnyContactEvent trigger function processed");
            var where = string.Empty;

            // Instantiate our convenient wrapper for the error-log queue
            var errQ = new ErrorQueueLogger(errors, "CrmUpdateHandler", nameof(DequeueAnyContactEvent));

            try
            {
                // deserialisation-as-JSON can throw an exception, which is why it's in a try..catch
                where = "deserialising request body";
                dynamic contactEvents = JsonConvert.DeserializeObject(requestBody);

                // If body is empty or not JSON, just return
                if (contactEvents == null)
                {
                    log.LogWarning("Contact information was empty or not JSON");
                    errQ.LogError("Contact information was empty or not JSON");
                    return;
                }

                // If JSON in the request body is not an array, just return
                var gotExpectedType = contactEvents is Newtonsoft.Json.Linq.JArray;
                if (!gotExpectedType)
                {
                    log.LogWarning("Contact information not an array of JSON objects");
                    errQ.LogError("Contact information not an array of JSON objects");
                    return;
                }

                // Reference info: When the phone number for contact 451 was changed, we got:
                //[
                //{ "objectId":451,
                //"propertyName":"phone",
                //"propertyValue":"12345",
                //"changeSource":"CRM_UI",
                //"eventId":1040856604,
                //"subscriptionId":111557,
                //"portalId":5618470,       <- that's Starling
                //"appId":191749,
                //"occurredAt":1554383495704,
                //"subscriptionType":"contact.propertyChange",
                //"attemptNumber":0}
                //]

                // Declare a couple of lists to contain the actual objects that we will pass to Event Grid
                var newContacts     = new List <NewContactEvent>();
                var updatedContacts = new List <UpdatedContactEvent>();

                // Now we have to reach back into Hubspot to retrieve the rest of the Contact details
                // When a new contact is created in Hubspot, we might get many events - usually a bunch of property-change events
                // followed by a contract-creation event. To avoid duplicate notifications, we need to filter out any "update"
                // events that are rendered redundant by the presence of a "new" event
                var curatedEvents = new HashSet <CuratedHubspotEvent>(new CuratedHubspotEventComparer());
                var newContactIds = new List <string>();

                where = "accessing contact event properties";
                foreach (var contactEvent in contactEvents)
                {
                    string objectId           = contactEvent?.objectId;
                    string eventId            = contactEvent?.eventId;
                    string subscriptionType   = contactEvent?.subscriptionType;
                    string attemptNumber      = contactEvent?.attemptNumber;
                    string changePropertyName = contactEvent?.propertyName;

                    // Temporary hack till our Flow can update folder names for us
                    //  - didn't work well. On new contact, you get 4 update events + a create event, so the code below sent 4 emails
                    //if (changePropertyName == "firstname" || changePropertyName == "lastname")
                    //{
                    //    if (subscriptionType != "contact.creation")
                    //    {
                    //        await errors.AddAsync(nameof(DequeueAnyContactEvent) + ": Name change for contact " + objectId + ". Check the Houses folder");
                    //    }
                    //}

                    log.LogInformation("Attempt number {0} for contact {1}: {2}", attemptNumber, objectId, subscriptionType);

                    switch (subscriptionType)
                    {
                    case "contact.creation":
                        newContactIds.Add(objectId);
                        curatedEvents.Add(new CuratedHubspotEvent(objectId, eventId, isNew: true));
                        break;

                    case "contact.propertyChange":
                        curatedEvents.Add(new CuratedHubspotEvent(objectId, eventId, isNew: false));
                        break;

                    default:
                        log.LogWarning("Unexpected subscriptionType from HubSpot: " + subscriptionType);
                        errQ.LogError("Unexpected subscriptionType from HubSpot: " + subscriptionType);
                        break;
                    }
                }

                where = "removing update messages that duplicate new messages";
                foreach (var newId in newContactIds)
                {
                    curatedEvents.RemoveWhere(c => c.Vid == newId && c.IsNew == false);
                }

                // Now we've tidied things up and the events are now unique, we can reach back into HubSpot to fetch the details of the contacts
                foreach (var contactEvent in curatedEvents)
                {
                    string objectId = contactEvent.Vid;

                    where = "retrieving contact " + objectId;
                    var contactResult = await this._hubSpotAdapter.RetrieveHubspotContactById(objectId, fetchPreviousValues : true, log : log, isTest : false);

                    NewContactEvent     newContactEvent     = null;
                    UpdatedContactEvent updatedContactEvent = null;

                    log.LogInformation("Response StatusCode from contact retrieval: " + (int)contactResult.StatusCode);

                    // Check Status Code. If we got the Contact OK, then raise the appropriate event.
                    if (contactResult.StatusCode == HttpStatusCode.OK)
                    {
                        //log.LogInformation(resultText);

                        if (contactEvent.IsNew)
                        {
                            // Extract some details of the contact, to send to Event Grid
                            newContactEvent = new NewContactEvent(contactEvent.EventId, contactResult.Payload);
                            newContacts.Add(newContactEvent);
                            log.LogInformation("New Contact: {0}", contactResult.Payload.email);
                        }
                        else
                        {
                            updatedContactEvent = new UpdatedContactEvent(contactEvent.EventId, contactResult.Payload);

                            updatedContacts.Add(updatedContactEvent);
                            log.LogInformation("Updating " + contactResult.Payload.email);
                        }
                    }
                    else
                    {
                        log.LogError("Error: HTTP {0} {1} ", (int)contactResult.StatusCode, contactResult.StatusCode);
                        log.LogError("Contact ID: {0} ", objectId);
                        log.LogInformation(contactResult.ErrorMessage);
                        log.LogInformation("Original Request Body:");
                        log.LogInformation(requestBody);

                        errQ.LogError("Failed to retrieve contact " + objectId + " from HubSpot. " + contactResult.ErrorMessage);
                    }
                }


                // With the filtering done, now we raise separate events for each new and updated contact.
                // See https://docs.microsoft.com/en-us/azure/event-grid/post-to-custom-topic
                // and https://docs.microsoft.com/en-us/azure/event-grid/monitor-event-delivery

                where = "raising UpdatedContact events";
                await EventGridAdapter.RaiseUpdatedContactEventsAsync(updatedContacts);

                where = "raising NewContact events";
                //log.LogInformation(JsonConvert.SerializeObject(newContacts));
                await EventGridAdapter.RaiseNewContactEventsAsync(newContacts);
            }
            catch (Exception ex)
            {
                log.LogError($"Request failed {where}: {ex.Message}");
                errQ.LogError("Exception " + where + ": " + ex.Message);
            }
        }
        public async Task Run([QueueTrigger("hubspot-contacts-needing-updates")] string diffJson,
                              [Queue("error-notification")] IAsyncCollector <string> errors,
                              ILogger log)
        {
            log.LogInformation($"DequeueContactDiffs processed: {diffJson}");

            // Instantiate our convenient wrapper for the error-log queue
            var errQ = new ErrorQueueLogger(errors, "CrmUpdateHandler", nameof(DequeueContactDiffs));

            // Typical 'diff' packet coming into us here. Note that the names are friendly names, so we have to resolve them
            // to internal names (this forces us to check the input too, which is an extra security hurdle for the bad guys)
            //
            // {
            //   "crmid": "012345",
            //   "changes":[
            //     {
            //       "name":"First",
            //       "value":"Bill"
            //     },
            //     {
            //       "name":"Last",
            //       "value":"McPherson"
            //     }
            //   ]
            // }

            string where = string.Empty;
            try
            {
                where = "deserialising message text";
                dynamic userdata = JsonConvert.DeserializeObject(diffJson);

                where = "accessing crmid";
                string crmid = userdata.crmid;

                if (string.IsNullOrEmpty(crmid))
                {
                    throw new CrmUpdateHandlerException("crmid not found in message");
                }

                where = "accessing changes";
                if (userdata.changes == null)
                {
                    log.LogWarning("No 'changes' found");
                    return;
                }

                where = "testing changes datatype";
                if (!(userdata.changes is JArray))
                {
                    throw new CrmUpdateHandlerException("'changes' property was not an array");
                }

                where = "extracting changes";
                var props = new HubSpotContactProperties();
                foreach (dynamic change in userdata.changes)
                {
                    string displayName          = change.name;  // convert to string
                    string value                = change.value; // convert to string
                    string internalPropertyName = ResolveFriendlyNameToHubspotPropertyName(displayName);
                    if (string.IsNullOrEmpty(internalPropertyName))
                    {
                        //log.LogWarning($"Could not resolve '{displayName}' to a hubspot property name");
                    }
                    else
                    {
                        props.Add(internalPropertyName, value);
                    }
                }

                if (props.properties.Count == 0)
                {
                    // nothing to do
                    return;
                }

                where = "updating contact details";
                var crmAccessResult = await _hubSpotAdapter.UpdateContactDetailsAsync(crmid, props, log, isTest : false);

                if (crmAccessResult.StatusCode != System.Net.HttpStatusCode.OK)
                {
                    log.LogError($"Error {crmAccessResult.StatusCode} updating HubSpot contact {crmid}: {crmAccessResult.ErrorMessage}");
                    errQ.LogError("Error " + crmAccessResult.StatusCode + " updating HubSpot contact {crmid}: " + crmAccessResult.ErrorMessage);
                }
            }
            catch (Exception ex)
            {
                errQ.LogError($"Exception {where}: {ex.Message}");
            }
        }
Exemple #5
0
        public async Task Run([EventGridTrigger] EventGridEvent eventGridEvent,
                              [Queue("error-notification")] IAsyncCollector <string> errors,
                              ILogger log)
        {
            log.LogInformation(eventGridEvent.Data.ToString());

            // Instantiate our convenient wrapper for the error-log queue
            var errQ = new ErrorQueueLogger(errors, "CrmUpdateHandler", nameof(UpdateContractStatusHandler));

            // The shape of the data looks much like the following
            // [
            // {
            //    "contractstate":"Sent",
            //    "eventtype":"ContractSent",
            //    "installationId":"124",
            //    "customeremail":"*****@*****.**",
            //    "senddate":"2019-07-26T01:54:30.85Z",
            //    "signingdate":null,
            //    "rejectionreason":null
            // }
            // ]

            string where = string.Empty;
            try
            {
                where = "deserializing Event Grid notification structure";
                var contractStatusNotification = JsonConvert.DeserializeObject <CustomerContractData>(eventGridEvent.Data.ToString());

                if (contractStatusNotification == null)
                {
                    log.LogError("Event deserialisation error\n" + eventGridEvent.Data.ToString());
                    errQ.LogError("Event deserialisation error\n" + eventGridEvent.Data.ToString());
                    return;
                }

                // OK, we got enough info to proceed. Do some checks
                where = "checking Event Grid package for consistency";
                switch (contractStatusNotification.ContractState)
                {
                case "Sent":
                    break;

                case "Signed":
                    // If the contract was signed, the signing date must be present
                    if (!contractStatusNotification.SigningDate.HasValue)
                    {
                        log.LogError("Signing Date missing for installation " + contractStatusNotification.InstallationId);
                        errQ.LogError("Signing Date missing for installation " + contractStatusNotification.InstallationId);
                        return;
                    }
                    break;

                case "Rejected":
                    // If the contract was rejected, the signing date must be present (in this case, it's the rejection date)
                    if (!contractStatusNotification.SigningDate.HasValue)
                    {
                        log.LogError("Signing Date missing for rejected installation " + contractStatusNotification.InstallationId);
                        errQ.LogError("Signing Date missing for rejected installation " + contractStatusNotification.InstallationId);
                        return;
                    }

                    // We can't force the user to fill out a rejection reason. But we'll log it
                    if (string.IsNullOrEmpty(contractStatusNotification.RejectionReason))
                    {
                        log.LogWarning("Rejection reason was missing for installation " + contractStatusNotification.InstallationId);
                    }
                    break;

                case "ContractForwarded":
                    // TODO: Handle this
                    break;

                default:
                    // If somebody introduces a new contract state, we'll know about it soon enough.
                    log.LogError("Unknown contract state: '" + contractStatusNotification.ContractState + "' for installation " + contractStatusNotification.InstallationId);
                    errQ.LogError("Unknown contract state: '" + contractStatusNotification.ContractState + "' for installation " + contractStatusNotification.InstallationId);
                    return;
                }

                // Now we can update the indicated installation in HubSpot
                where = "patching '" + contractStatusNotification.CustomerEmail + "' in hubspot";
                var contactUpdateResult = await _hubSpotAdapter.UpdateContractStatusAsync(contractStatusNotification.CustomerEmail, contractStatusNotification.ContractState, log, isTest : false);

                if (contactUpdateResult.StatusCode != System.Net.HttpStatusCode.OK)
                {
                    log.LogError("Error updating contract state for '" + contractStatusNotification.CustomerEmail + "': " + contactUpdateResult.ErrorMessage);
                    errQ.LogError($"Error updating contract state for {contractStatusNotification.CustomerEmail}' (installation {contractStatusNotification.InstallationId}): {contactUpdateResult.ErrorMessage}");
                }
            }
            catch (Exception ex)
            {
                log.LogError($"Exception {where}: {ex.Message}");
                errQ.LogError($"Exception {where}: {ex.Message}");
                return;
            }
        }