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)); } }
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}"); } }
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; } }