Sunday, 24 February 2019

Holiday Price Scraper (Serverless Architecture - FaaS) - Part 3


Phase 3


This article follows on from part two which you can read here: http://dale-dev.blogspot.com/2019/01/holiday-price-scraper-serverless_10.html, in phase 3 I want to accomplish the following things...

  • AWS fails to send emails via Gmail, I have allowed insecure apps in Gmail enabled and still email fails to send, will setup sending emails using SES.
  • Implement email templates to use when sending an email with all the data that I would like to see.
  • Handle when the holiday is no longer available.
  • Complete README.md file


AWS Failing to send email


Looking online there were a few guides on how to send emails using Amazons SES. I refactored my code to allow me to provide a different implementation of a mail sender interface, I wrote the following code to perform the sending of the email

AwsSesConfig.java

 @Configuration  
 public class AwsSesConfig {  
   @Bean  
   public AmazonSimpleEmailService client() {  
     return AmazonSimpleEmailServiceClientBuilder.standard()  
         .withRegion(Regions.EU_WEST_1).build();  
   }  
 }  


SesMailService.java


 @Service  
 public class SesMailService implements MailService {  
   private final Logger log = LoggerFactory.getLogger(this.getClass());  
   private AmazonSimpleEmailService client;  
   /**  
    * @param client  
    */  
   public SesMailService(AmazonSimpleEmailService client) {  
     this.client = client;  
   }  
   @Override  
   public void sendEmail(String to, String subject, String html, byte[] chart) {  
     log.debug("Attempting to send email via Amazon SES client : {}", client);  
     SendEmailRequest request = new SendEmailRequest();  
     request.setSource("Holiday Price Scraper <daledev.pricescraper@gmail.com>");  
     request.setDestination(new Destination(Collections.singletonList(to)));  
     request.setMessage(createEmailMessage(subject, html));  
     log.debug("Send email request : {}", request);  
     client.sendEmail(request);  
   }  
   private Message createEmailMessage(String subject, String html) {  
     return new Message()  
         .withSubject(new Content().withCharset("UTF-8").withData(subject))  
         .withBody(new Body().withHtml(new Content().withCharset("UTF-8").withData(html)));  
   }  
 }  


When running the code I got the following error:

  "errorMessage": "User `arn:aws:sts::675239832622:assumed-role/MyPriceHistoryDynamoDB/holidayPriceChecker' is not authorized to perform `ses:SendEmail' on resource `arn:aws:ses:eu-west-1:675239832622:identity/daledev.pricescraper@gmail.com' (Service: AmazonSimpleEmailService; Status Code: 403; Error Code: AccessDenied; Request ID: b120b161-176b-11e9-888e-8ddbf253bcdf)",

This was just a matter of using IAM to grant the following permission "AmazonSESFullAccess" to my role that the Lambda function uses.

Registering the sending email address



Next, when I run the function I got another error which I was expecting as I had read the following documentation https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html:

Email address is not verified. The following identities failed the check in region region: identity1, identity2, identity3—You are trying to send email from an email address or domain that you have not verified with Amazon SES. This error could apply to the "From", "Source", "Sender", or "Return-Path" address. If your account is still in the Amazon SES sandbox, you also must verify every recipient email address except for the recipients provided by the Amazon SES mailbox simulator. If Amazon SES is not able to show all of the failed identities, the error message ends with an ellipsis.

I followed the instructions and registered my email address but I kept getting an error about an unverified email address, the email address is reported in the error was actually the recipient, nowhere in the above-quoted text did it mention registering recipient email addresses and that would not make any sense to have to do so to me, however I tried it and it worked which was a bit of a head-scratcher. Further searching I found the explanation for this here: https://docs.aws.amazon.com/ses/latest/DeveloperGuide/request-production-access.html ...

To help prevent fraud and abuse, and to help protect your reputation as a sender, we apply certain restrictions to new Amazon SES accounts.
We place all new accounts in the Amazon SES sandbox. While your account is in the sandbox, you can use all of the features of Amazon SES. However, when your account is in the sandbox, we apply the following restrictions to your account:
You can only send mail to verified email addresses and domains, or to the Amazon SES mailbox simulator.
You can only send mail from verified email addresses and domains.
Note
This restriction applies even when your account is not in the sandbox.
You can send a maximum of 200 messages per 24-hour period.
You can send a maximum of 1 message per second.
When your account is out of the sandbox, you can send email to any recipient, regardless of whether the recipient's address or domain is verified. However, you still have to verify all identities that you use as "From", "Source", "Sender", or "Return-Path" addresses.

I wish they made some reference to that in the registering email addresses section, would have saved a little pain but I got it working in the end.

Email Templates



In order to give this a cleaner structure my existing MailService changed to effectively be a mail sender service which is responsible for sending the emails, new mail service is now responsible for generating the email content and then passing that content to the mail sender service.

 public interface MailService {  
   void sendPriceDropMail(PriceHistory priceHistory);  
   void sendPriceIncreaseMail(PriceHistory priceHistory);  
 }  

I decided to play safe here and go with a framework I was already familiar with, Apache Velocity. What I wanted to be displayed in the email is a little summary explain that the price has gone up or down, a table showing the history of prices and a line chart. The velocity templates I create for these emails

price-increase.vm


 <html>  
 <head>  
   #parse ("static/style.vm")  
 </head>  
 <body>  
   <h2>$data.criterion.description</h2>  
   <b>Price Increased : &#163;$data.lastRecordedPrice.price</b>  
   #parse ("static/price-history-table.vm")  
   <p>  
     <img src="$reportImageSrc"/>  
   </p>  
 </body>  
 </html>  


price-drop.vm


 <html>  
 <head>  
   #parse ("static/style.vm")  
 </head>  
 <body>  
   <h2>$data.criterion.description</h2>  
   <table>  
     <tr>  
       <td>Price Dropped</td>  
       <td style="font-weight: bold;">&#163;$data.lastRecordedPrice.price</td>  
     </tr>  
     <tr>  
       <td>Cheapest ever captured?</td>  
       <td style="font-weight: bold;">  
         #if($data.currentPriceCheapestCaptured)  
           Yes  
         #else  
           No  
         #end  
       </td>  
     </tr>  
   </table>  
   #parse ("static/price-history-table.vm")  
   <p>  
     <img src="$reportImageSrc"/>  
   </p>  
 </body>  
 </html>  


price-history-table.vm


 <h2>Price History</h2>  
 <table>  
 <tr style="font-weight: bold;">  
   <td width="150">From</td>  
   <td width="150">To</td>  
   <td width="150">Depart Date</td>  
   <td>Price</td>  
 </tr>  
 #foreach($history in $data.history)  
 <tr>  
   <td>$date.format('dd/MM/yyyy HH:mm', $history.firstRetrievedDate)</td>  
   <td>$date.format('dd/MM/yyyy HH:mm', $history.lastRetrievedDate)</td>  
   <td>$date.format('dd/MM/yyyy (EEE)', $history.departDate)</td>  
   <td>&#163;$history.price</td>  
 </tr>  
 #end  
 </table>  

I used a library called JFreeChart to generate the chart from the data in the Price History Entity. Embedding the chart into the email turned out to be a bigger pain in the ass than I ever realized, there is a nice article detailing different problems with the different ways to embed an image https://sendgrid.com/blog/embedding-images-emails-facts/ so what I decide to do was to upload the generated image to Imgur and embed the URL tot he image uploaded there which seems to work.

This is the kind of email I have ended up with...


As you can see the has taken a dive the last week, about £100 less than I paid 😬

Handle when the holiday is no longer available


I wanted to make sure I can detect when a holiday is no longer is available, in order to do this I need to add some code into FirstChoicePriceScraper.java, before I did that I wanted to clean up the FirstChoiceHolidayQuoteDao.java, I love to adhere to clean code principles best I can, the existing class was doing too much, there is a good portion of the code in there responsible for building up the URL required to fetch prices, this needed refactoring out, I created a builder class to handle this.

 public class FirstChoiceFetchUrlBuilder {  
   private static final String SCRAPE_URL = "https://www.firstchoice.co.uk/holiday/packages";  
   private String airportCodes;  
   private String accommodationRef;  
   private String date;  
   private String until;  
   private int flexibleDays = 0;  
   private int numberOfAdults = 0;  
   private int[] childrenAges = new int[0];  
   private int durationCode;  
   private String searchRequestType = "ins";  
   private String searchType = "search";  
   private boolean sp = true;  
   private boolean multiSelect = true;  
   private String room;  
   private boolean isVilla = false;  
   private FirstChoiceFetchUrlBuilder() {  
   }  
   public static FirstChoiceFetchUrlBuilder get() {  
     return new FirstChoiceFetchUrlBuilder();  
   }  
   /**  
    * @return  
    */  
   public String build() {  
     StringBuilder url = new StringBuilder(SCRAPE_URL);  
     url.append("?airports[]=").append(airportCodes);  
     url.append("&units[]=").append(accommodationRef);  
     url.append("&when=").append(date);  
     url.append("&until=").append(blankStringNulls(until));  
     url.append("&flexibility=").append(flexibleDays > 0);  
     url.append("&flexibleDays=").append(flexibleDays);  
     url.append("&noOfAdults=").append(numberOfAdults);  
     url.append("&noOfChildren=").append(childrenAges.length);  
     url.append("&childrenAge=").append(IntStream.of(childrenAges).mapToObj(String::valueOf).collect(Collectors.joining("|")));  
     url.append("&duration=").append(durationCode);  
     url.append("&searchRequestType=").append(searchRequestType);  
     url.append("&searchType=").append(searchType);  
     url.append("&sp=").append(sp);  
     url.append("&multiSelect=").append(multiSelect);  
     url.append("&room=").append(blankStringNulls(room));  
     url.append("&isVilla=").append(isVilla);  
     return url.toString();  
   }  
   public FirstChoiceFetchUrlBuilder withAirports(Airport[] airports) {  
     this.airportCodes = Stream.of(airports).map(Airport::getCode).collect(Collectors.joining("|"));  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withAccommodationRef(String accommodationRef) {  
     this.accommodationRef = accommodationRef;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withDate(String date) {  
     this.date = date;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withUntil(String until) {  
     this.until = until;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withFlexibleDays(int flexibleDays) {  
     this.flexibleDays = flexibleDays;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withNumberOfAdults(int numberOfAdults) {  
     this.numberOfAdults = numberOfAdults;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withChildrenAges(int[] childrenAges) {  
     this.childrenAges = childrenAges;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withDuration(int durationInDays) {  
     if (durationInDays == 14) {  
       this.durationCode = 1413;  
     } else if (durationInDays == 10 || durationInDays == 11) {  
       this.durationCode = 1014;  
     } else {  
       this.durationCode = 7114; // 7 days  
     }  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withSearchRequestType(String searchRequestType) {  
     this.searchRequestType = searchRequestType;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withSearchType(String searchType) {  
     this.searchType = searchType;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withSp(boolean sp) {  
     this.sp = sp;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withMultiSelect(boolean multiSelect) {  
     this.multiSelect = multiSelect;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withRoom(String room) {  
     this.room = room;  
     return this;  
   }  
   public FirstChoiceFetchUrlBuilder withVilla(boolean villa) {  
     isVilla = villa;  
     return this;  
   }  
   private String blankStringNulls(Object value) {  
     return value == null ? "" : value.toString();  
   }  
 }  

This simplified the First Choice DAO code. TheFirstChoicePriceScraper,java code required the changes when the holiday is no longer available there

   private JSONArray getPriceJsonArray(String json) {  
     try {  
       JSONObject jsonObject = new JSONObject(json);  
       // searchResult > holidays > dateSliderViewData  
       if (jsonObject.has("searchResult")) {  
         JSONObject searchResultJsonObject = jsonObject.optJSONObject("searchResult");  
         return searchResultJsonObject.optJSONArray("dateSliderViewData");  
       } else {  
         log.debug("Holiday result JSON contains no search results");  
         return new JSONArray();  
       }  
     } catch (Exception e) {  
       log.error("Could not extract price from search result JSON", e);  
       return new JSONArray();  
     }  
   }  

The JSON holding price data contained searchResult attribute, the text displayed to the user when the holiday is no longer available is also extractable from a different portion of the response HTML, I added the following method to extract that.

   private HolidayQuoteResults discoverNoPriceReason(Document htmlDocument) {  
     HolidayQuoteResults results = new HolidayQuoteResults();  
     Element holidayRemoveBannerElement = htmlDocument.getElementById(HOLIDAYS_ALL_GONE_ELEMENT_ID);  
     if (holidayRemoveBannerElement != null) {  
       log.debug("Holiday all gone element found : {}", holidayRemoveBannerElement.text());  
       results.setStatus(QuoteResultStatus.ALL_GONE);  
     }  
     return results;  
   }  

With this information, I now make this DAO return a response object which includes the fetch status instead of just prices so that I can inform the calling DAO that the holiday is no longer available

Source Code


What's Next in Part 4


  • Deploy in Azure
  • Support TUI Scraping

Thursday, 10 January 2019

Holiday Price Scraper (Serverless Architecture - FaaS) - Part 2


Phase 2


So this article follows on from part one which you can read here: http://dale-dev.blogspot.com/2019/01/holiday-price-scraper-serverless.html, in phase 2 I want to accomplish the following things...

  • The function in AWS is not working when running when triggered by the timer, this needs resolving.
  • The function in AWS is not persisting the history of retrieved prices, I will be creating a DynamoDB DAO to persist this data.
  • Search criteria to be persisted as saved searches rather than hard coded.


AWS Trigger issue


The timer job I set up in AWS which triggers the function fails, the error from the log shows the following...


 REPORT RequestId: db8ec301-0fbe-11e9-902c-ad0f3698404b     Duration: 442.69 ms     Billed Duration: 500 ms Memory Size: 512 MB     Max Memory Used: 98 MB       
 START RequestId: db8ec301-0fbe-11e9-902c-ad0f3698404b Version: $LATEST  
 An error occurred during JSON parsing: java.lang.RuntimeException  
 java.lang.RuntimeException: An error occurred during JSON parsing  
 Caused by: java.io.UncheckedIOException: com.fasterxml.jackson.databind.JsonMappingException: Can not deserialize instance of java.lang.String out of START_OBJECT token  
      at [Source: lambdainternal.util.NativeMemoryAsInputStream@78b729e6; line: 1, column: 1]  
 Caused by: com.fasterxml.jackson.databind.JsonMappingException: Can not deserialize instance of java.lang.String out of START_OBJECT token  
      at [Source: lambdainternal.util.NativeMemoryAsInputStream@78b729e6; line: 1, column: 1]  
      at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:148)  
      at com.fasterxml.jackson.databind.DeserializationContext.mappingException(DeserializationContext.java:857)  
      at com.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:62)  
      at com.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:11)  
      at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:1511)  
      at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1102)  
 END RequestId: db8ec301-0fbe-11e9-902c-ad0f3698404b  


From that, it's showing that there is a problem de-serializing the input from the trigger. I didn't actually configure an input, I assumed a null value would be passed and everything would be ok but I assumed wrong (What's that they say about assumptions?).

So going back into AWS, I can see there is an option to configure the input passed from the cloud event so I made the following change





DynamoDB Persistence

Setup


Under DynamoDB, I have selected "Create Table" and enter the following...

Table Name : PriceHistory
Primary key : searchUUID



New DynamoDB DAO


Following this guide: https://www.javaworld.com/article/3248595/application-development/serverless-computing-with-aws-lambda-part-2.amp.html I created the following code

pom.xml

     <dependency>  
       <groupId>com.amazonaws</groupId>  
       <artifactId>aws-java-sdk-dynamodb</artifactId>  
       <version>1.11.475</version>  
     </dependency>  


DynamoDBPriceStoreDao

 package com.daledev.holidaypricescrapper.dao.aws;  
 import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;  
 import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;  
 import com.amazonaws.services.dynamodbv2.document.*;  
 import com.daledev.holidaypricescrapper.dao.PriceStoreDao;  
 import com.daledev.holidaypricescrapper.domain.PriceHistory;  
 import org.slf4j.Logger;  
 import org.slf4j.LoggerFactory;  
 import org.springframework.stereotype.Repository;  
 import java.util.ArrayList;  
 import java.util.List;  
 import java.util.UUID;  
 /**  
  * @author dale.ellis  
  * @since 04/01/2019  
  */  
 @Repository  
 public class DynamoDBPriceStoreDao implements PriceStoreDao {  
   private final Logger log = LoggerFactory.getLogger(this.getClass());  
   @Override  
   public List<PriceHistory> getPriceHistories() {  
     List<PriceHistory> histories = new ArrayList<>();  
     log.debug("Scanning For all PriceHistory items");  
     ItemCollection<ScanOutcome> scanResults = getHistoryTable().scan();  
     for (Item item : scanResults) {  
       log.debug("Item {} from results returned in results", item);  
       PriceHistory priceHistory = PriceHistoryMapper.mapToPriceHistory(item);  
       if (priceHistory != null) {  
         histories.add(priceHistory);  
       }  
     }  
     log.debug("Returning {} price histories", histories.size());  
     return histories;  
   }  
   @Override  
   public PriceHistory getPriceHistory(String searchUuid) {  
     log.debug("Attempting to retrieve PriceHistory with ID : {}", searchUuid);  
     Item persistedHistory = getHistoryTable().getItem(PriceHistoryMapper.PRIMARY_KEY_NAME, searchUuid);  
     if (persistedHistory != null) {  
       log.debug("History found in DynamoDB. {}", persistedHistory.toJSONPretty());  
       return PriceHistoryMapper.mapToPriceHistory(persistedHistory);  
     }  
     return new PriceHistory();  
   }  
   @Override  
   public void storePrice(PriceHistory priceHistory) {  
     log.debug("Storing history with ID : {}", priceHistory.getSearchUUID());  
     Item item = PriceHistoryMapper.mapFromPriceHistory(priceHistory);  
     PutItemOutcome putItemOutcome = getHistoryTable().putItem(item);  
     log.debug("Create response : {}", putItemOutcome);  
   }  
   private Table getHistoryTable() {  
     log.debug("Creating Table API object for table : {}", PriceHistoryMapper.TABLE_NAME);  
     DynamoDB dynamoDB = getDynamoDB();  
     return dynamoDB.getTable(PriceHistoryMapper.TABLE_NAME);  
   }  
   private DynamoDB getDynamoDB() {  
     log.debug("Creating AmazonDynamoDB client");  
     AmazonDynamoDB client = AmazonDynamoDBClientBuilder.defaultClient();  
     log.debug("AmazonDynamoDB client : {}", client);  
     return new DynamoDB(client);  
   }  
 }  


Got the following error when running the function in AWS ...

 User: arn:aws:sts::675239832622:assumed-role/holidayCheckRole/holidayPriceCheck is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:eu-west-2:675239832622:table/PriceHistory (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: AccessDeniedException; Request ID: K8T3CA4G8RMTBINN546UDMI5RFVV4KQNSO5AEMVJF66Q9ASUAAJG): com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException  
 com.amazonaws.services.dynamodbv2.model.AmazonDynamoDBException: User: arn:aws:sts::675239832622:assumed-role/holidayCheckRole/holidayPriceCheck is not authorized to perform: dynamodb:GetItem on resource: arn:aws:dynamodb:eu-west-2:675239832622:table/PriceHistory (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: AccessDeniedException; Request ID: K8T3CA4G8RMTBINN546UDMI5RFVV4KQNSO5AEMVJF66Q9ASUAAJG)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.handleErrorResponse(AmazonHttpClient.java:1695)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeOneRequest(AmazonHttpClient.java:1350)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeHelper(AmazonHttpClient.java:1101)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.doExecute(AmazonHttpClient.java:758)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeWithTimer(AmazonHttpClient.java:732)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.execute(AmazonHttpClient.java:714)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutor.access$500(AmazonHttpClient.java:674)  
     at com.amazonaws.http.AmazonHttpClient$RequestExecutionBuilderImpl.execute(AmazonHttpClient.java:656)  
     at com.amazonaws.http.AmazonHttpClient.execute(AmazonHttpClient.java:520)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.doInvoke(AmazonDynamoDBClient.java:4192)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.invoke(AmazonDynamoDBClient.java:4159)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.executeGetItem(AmazonDynamoDBClient.java:2028)  
     at com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient.getItem(AmazonDynamoDBClient.java:1995)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.doLoadItem(GetItemImpl.java:77)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.getItemOutcome(GetItemImpl.java:40)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.getItemOutcome(GetItemImpl.java:99)  
     at com.amazonaws.services.dynamodbv2.document.internal.GetItemImpl.getItem(GetItemImpl.java:111)  
     at com.amazonaws.services.dynamodbv2.document.Table.getItem(Table.java:624)  
     at com.daledev.holidaypricescrapper.dao.aws.DynamoDBPriceStoreDao.getPriceHistory(DynamoDBPriceStoreDao.java:28)  
     at com.daledev.holidaypricescrapper.service.PriceRetrieverServiceImpl.priceCheck(PriceRetrieverServiceImpl.java:82)  
     at com.daledev.holidaypricescrapper.function.PriceCheckFunction.apply(PriceCheckFunction.java:30)  
     at com.daledev.holidaypricescrapper.function.PriceCheckFunction.apply(PriceCheckFunction.java:15)  
     at org.springframework.cloud.function.core.FluxFunction.lambda$apply$0(FluxFunction.java:47)  
     at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:107)  
     at reactor.core.publisher.FluxJust$WeakScalarSubscription.request(FluxJust.java:99)  
     at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.request(FluxMapFuseable.java:156)  
     at reactor.core.publisher.BlockingIterable$SubscriberIterator.onSubscribe(BlockingIterable.java:214)  
     at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onSubscribe(FluxMapFuseable.java:90)  
     at reactor.core.publisher.FluxJust.subscribe(FluxJust.java:70)  
     at reactor.core.publisher.FluxMapFuseable.subscribe(FluxMapFuseable.java:63)  
     at reactor.core.publisher.Flux.subscribe(Flux.java:6877)  
     at reactor.core.publisher.BlockingIterable.iterator(BlockingIterable.java:80)  
     at org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler.result(SpringBootRequestHandler.java:54)  
     at org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler.handleRequest(SpringBootRequestHandler.java:49)  

The lambda function required the role is assigned to have AmazonDynamoDBFullAccess granted, I added this in the AWS IAM (Identity and Access Management) area.


Persist Search Criteria

I modified the JSON document to also hold the search criteria as well as the history of price changes, example content...

 {  
  "searchUUID" : "495f1431-5526-4083-997d-f91849be966b",  
  "criterion" : {  
   "description" : "Holiday Village in KOS 1 week in May-June 2019",  
   "startDate" : "2019-05-01T00:00:00.000+0000",  
   "endDate" : "2019-06-30T00:00:00.000+0000",  
   "airports" : [ {  
    "code" : "STN",  
    "uniqueId" : null  
   } ],  
   "duration" : 7,  
   "accommodationRef" : "047406:HOTEL",  
   "numberOfAdults" : 2,  
   "childrenAges" : [ 2 ]  
  },  
  "history" : [ {  
   "firstRetrievedDate" : "2019-01-06T21:35:59.700+0000",  
   "lastRetrievedDate" : "2019-01-06T21:53:00.319+0000",  
   "cheapestQuotes" : [ {  
    "airport" : {  
     "code" : "STN",  
     "uniqueId" : null  
    },  
    "date" : "2019-05-08T00:00:00.000+0000",  
    "price" : 613.0  
   } ]  
  }, {  
   "firstRetrievedDate" : "2019-01-07T14:16:22.610+0000",  
   "lastRetrievedDate" : "2019-01-07T17:47:00.076+0000",  
   "cheapestQuotes" : [ {  
    "airport" : {  
     "code" : "STN",  
     "uniqueId" : null  
    },  
    "date" : "2019-05-08T00:00:00.000+0000",  
    "price" : 611.0  
   } ]  
  }, {  
   "firstRetrievedDate" : "2019-01-08T12:34:23.896+0000",  
   "lastRetrievedDate" : "2019-01-08T12:34:23.896+0000",  
   "cheapestQuotes" : [ {  
    "airport" : {  
     "code" : "STN",  
     "uniqueId" : null  
    },  
    "date" : "2019-05-07T23:00:00.000+0000",  
    "price" : 613.0  
   } ]  
  } ],  
  "subscribers" : [ "dale.ellis@netcall.com", "daleellis1983@gmail.com" ],  
  "active" : true  
 }  


Other Changes


I did a pretty major refactor of my project, I split my code into a multi-module project, the issue I was having was that I was bundling the controller code for running the app locally in the AWS build, when running locally, the way I had it setup wouldn't compile as there were AWS dependencies that where mandatory which weren't included in a local build so I was getting errors.

New structure...




Module NameDescription
holiday-price-scraper-awsDynamoDB implementation for persistence, Spring cloud function adapter for AWS.
holiday-price-scraper-bootMain application, business logic
holiday-price-scraper-local-restFile system implementation for persistence, REST API to test locally
holiday-price-scraper-persistenceContains domain objects and DAO interfaces

Source Code



What's Next in Part 3


The desire is to be able to deploy this to other FaaS providers, but before I tackle Azure, I would like to tidy up some loose ends first...
  • AWS fails to send emails via Gmail, I have allowed insecure apps in Gmail enabled and still email fails to send, will setup sending emails using SES.
  • Implement email templates to use when sending an email with all the data that I would like to see.
  • Handle when the holiday is no longer available.
  • Complete README.md file

Sunday, 14 October 2018

Graph CRM - proof of concept


Introduction

Having worked on a CRM project in the past, there were things the product did well and things the product didn’t do so well or didn’t do at all. What I would like to do is take what I have done over the years and use that knowledge to build more complete product.

CRM Experience

Originally marketed as a CRM system, the product I worked on was more of a case management system and data integration view. The product has a rudimentary BPM engine to drive cases, it also has a integration piece also acting as a rudimentary BPM engine. The product had dashboards displaying data gathered from multiple sources, these 2 things were the key selling points of CRM. Other notable features of CRM, it also has a forms engine which allows for the designing of forms that can be used in the core application, a portal or mobile application.
CRM Systems


A typical system would usually consist of the following features (
Taken from https://www.salesforce.com/eu/learning-centre/crm/crm-systems/ and https://crm.walkme.com/components-customer-relationship-management/
)

Contact management

All the latest information about customers — from contact details to service conversations — is easily available to access and update.

Customer Service

Customer Relationship Management emphasises on collecting customer information and data, their purchase information and patterns as well as involves providing the collected information to the necessary and concerned departments. This makes customer service an essential component of CRM. Almost all the major departments including the sales department, marketing team and the management personnel are required to take steps to develop their awareness and understanding of the customer needs as well as complaints. This undoubtedly makes the business or the company to deliver quick and perfect solutions and assistance to the customers as well as cater to their needs which increases the dependability and trust of the customers and people on the organisation.

Lead management

Lead Management as the name suggests, refers to keeping the track of the sales leads as well as their distribution. The business that are benefited by this component of CRM the most are the sales industries, marketing firms and customer executive centres. It involves an efficient management of the campaigns, designing customised forms, finalising the mailing lists and several other elements. An extensive study of the purchase patterns of the customers as well as potential sales leads helps to capture the maximum number of sales leads to improve the sales.

HR Management

Human Resource Management involves the effective and correct use of human resource and skills at the specific moment and situation. This requires to be make sure that the skills and intellectual levels of the professionals match the tasks undertaken by them according to their job profiles. It is an essential component not only for the large scale corporations but the medium industries as well. It involves adopting an effective people strategy and studying the skills or the workforce and the growth being generated thereby designing and implementing the strategies needed accordingly with the aim of achieving development.

Sales forecasting

Forecasting reports enable salespeople to get better visibility over their pipelines, qualify leads more accurately, and see how close they are to hitting their targets. Sales managers can use reports to motivate and manage their people.

Instant messaging between employees

Real-time instant messaging functionality makes it easier for coworkers to ask and answer each other’s queries, for instance in support of a live sales opp or service interaction. Managers can check in on staff in the field, and employees can ask for instant feedback or support as needed.

Email tracking and integration with Outlook and Gmail

Syncing email clients instantly with the CRM system allows business people to get a complete view of their customers and leads without having to log in and out of different systems. Calendars and contacts can be viewed across every device, and emails can be created and managed from within a single workflow.

Marketing

Marketing is one of the most significant component of Customer Relationship Management and it refers to the promotional activities that are adopted by a company in order to promote their products. The marketing could be targeted to a particular group of people as well as to the general crowd. Marketing involves crafting and implementing strategies in order to sell the product. Customer Relationship Management assists in the marketing process by enhancing and improving the effectiveness of the strategies used for marketing and promotion. This is done by making an observation and study of the potential customers. It is a component that brings along various sub-elements or aspects. Some of the major elements of marketing are List Management, Campaign Management, Activity Management, Document Management, Call Management, Mass Emails and Reporting. The use of the aforesaid elements varies from business to business according to its nature and requirements as well as the target crowd.

Workflow Automation

A number of processes run simultaneously when it comes to the management and this requires an efficient cost cutting as well as the streamlining of all the processes.The phenomenon of doing so is known as Workflow Automation. It not only reduces the excess expenditure but also prevents the repetition of a particular task by different people by reducing the work and work force that is getting wasted for avoidable jobs. Routing out the paperwork and form filling are some of the elements of the process and it aims at preventing the loss of time and excess effort.

File and content sharing

Team members can upload information to a centrally stored location, and share easily and instantly with coworkers.

Dashboard-based analytics

Information is aggregated and presented in intuitive, meaningful dashboard displays that can be customised based on each individual’s priorities.

Graph Database

This project is born initially out of the desire for me to play around with graph databases and primarily the one I'm focusing on is NEO4J. If nothing else comes from this project it will give me the change to play around with NEO4J. The previous CRM product I work on had and issue with it's contact system, it was limited and adding relationships between different types of contacts was not easy, querying which join between different types of contact in most cases was very slow and in many cases was not possible at all. I've always felt a graph database sounds from the little I know of them to be a good good fit to easily add that capability so this project will allow me to find out if that is the case. From the table above, I believe a flexible entity system would allow for the handling of all that different types of data.

Plan

I have a very rough plan in mind for a proof of concept, I want to use NEO4J to build an entity system. Where before in my old CRM system, I spent a lot of effort in writing the case management functionality, this case management software was no where near being a full BPMN engine, the thought I had is that there most be open source BPMN engines that I could utilise to support case management, and after a small amount of time and watching videos on a particular product I found Apache Activiti. So the plan at the moment is simple, take Activiti, create an entity system with NEO4J integrate the 2 together into a CRM system.

Roadmap

  1. Create a entity system in NEO4J which allows me to define entities, I should be able to define from data the entity holds and how different types of entity can relate. 
  2. Implement entity searching to perform cross entity searching 
  3. Create basic GUI to support the entity creation and search features. 
  4. Setup Activiti 
  5. Implement user service and integrate with the CRM app and Activiti instance 
  6. Push cases into CRM system and display in GUI.