From 24f5fa8581cc6ebefc93e5a9c9210807c84c07da Mon Sep 17 00:00:00 2001 From: damiendaemon <71006393+damiendaemon@users.noreply.github.com> Date: Fri, 20 Nov 2020 19:03:09 +0300 Subject: [PATCH] Feature/master merge 20201119 (#18) Update: Bring master new commits to Daemn branch This work was done by Dae.mn Team. Co-authored-by: Akram Dweikat akram@akramdweikat.com Co-authored-by: Akram Dweikat akram.dweikat@dae.mn Co-authored-by: Damien Jade Duff damien.duff@daemonsolutions.com Co-authored-by: Joe Major joe.major@dae.mn --- aws/cloudformation-templates/base/tables.yaml | 15 +- images/product_image_coming_soon.png | Bin 0 -> 28229 bytes src/.env | 12 +- src/README.md | 2 + .../src/main.go | 15 +- src/docker-compose.yml | 13 + src/products/Dockerfile | 2 + src/products/README.md | 6 +- src/products/src/products-service/aws.go | 34 +- src/products/src/products-service/category.go | 4 + .../src/products-service/data/products.yaml | 84 ++++ src/products/src/products-service/handlers.go | 327 +++++++++++---- src/products/src/products-service/localdev.go | 306 ++++++++++++++ src/products/src/products-service/product.go | 14 +- .../src/products-service/repository.go | 387 ++++++++++++------ src/products/src/products-service/routes.go | 44 +- src/users/src/users-service/handlers.go | 32 +- src/users/src/users-service/repository.go | 24 +- src/web-ui/.env | 4 +- src/web-ui/src/analytics/AnalyticsHandler.js | 19 +- src/web-ui/src/authenticated/Profile.vue | 30 +- src/web-ui/src/public/CategoryDetail.vue | 7 +- src/web-ui/src/public/Main.vue | 50 ++- src/web-ui/src/public/ProductDetail.vue | 12 +- src/web-ui/src/public/Search.vue | 7 +- src/web-ui/src/public/components/Product.vue | 2 +- .../src/repositories/usersRepository.js | 17 +- src/web-ui/src/router/index.js | 9 +- src/web-ui/src/store/store.js | 16 +- .../1-Personalization/1.1-Personalize.ipynb | 31 +- 30 files changed, 1233 insertions(+), 292 deletions(-) create mode 100644 images/product_image_coming_soon.png create mode 100644 src/products/src/products-service/localdev.go diff --git a/aws/cloudformation-templates/base/tables.yaml b/aws/cloudformation-templates/base/tables.yaml index b6031a818..519cffc4b 100644 --- a/aws/cloudformation-templates/base/tables.yaml +++ b/aws/cloudformation-templates/base/tables.yaml @@ -44,14 +44,21 @@ Resources: Type: AWS::DynamoDB::Table Properties: AttributeDefinitions: - - - AttributeName: "id" + - AttributeName: "id" + AttributeType: "S" + - AttributeName: "name" AttributeType: "S" KeySchema: - - - AttributeName: "id" + - AttributeName: "id" KeyType: "HASH" BillingMode: "PAY_PER_REQUEST" + GlobalSecondaryIndexes: + - IndexName: name-index + KeySchema: + - AttributeName: "name" + KeyType: "HASH" + Projection: + ProjectionType: ALL ExperimentStrategyTable: Type: AWS::DynamoDB::Table diff --git a/images/product_image_coming_soon.png b/images/product_image_coming_soon.png new file mode 100644 index 0000000000000000000000000000000000000000..c61ffd99f16e29e294de4c47ac1796249ac5efd3 GIT binary patch literal 28229 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 literal 0 HcmV?d00001 diff --git a/src/.env b/src/.env index f8be3fb9b..12a05fe4c 100644 --- a/src/.env +++ b/src/.env @@ -6,10 +6,16 @@ APPDATA=/tmp AWS_REGION=us-west-2 AWS_DEFAULT_REGION=us-west-2 +# For services that depend on DDB, you can run it locally or connect to +# the real DDB running in your AWS account. To connect to it locally, +# uncomment the following line. Otherwise, comment the following line +# and setup your AWS credentials in environment variables. +DDB_ENDPOINT_OVERRIDE=http://ddb:3001 + # Product service variables: -# DynamoDB table names -DDB_TABLE_PRODUCTS= -DDB_TABLE_CATEGORIES= +# DynamoDB table names (if connecting to DDB in your AWS account, change accordingly) +DDB_TABLE_PRODUCTS=products +DDB_TABLE_CATEGORIES=categories # Root URL to use when building fully qualified URLs to product detail view WEB_ROOT_URL=http://localhost:8080 # Image root URL to use when building fully qualified URLs to product images diff --git a/src/README.md b/src/README.md index 7c6aa8a5f..b03dd65d9 100644 --- a/src/README.md +++ b/src/README.md @@ -14,6 +14,8 @@ Docker Compose will load the [.env](./.env) file to resolve environment variable Some services, such as the [products](./products) and [recommendations](./recommendations) services, need to access AWS services running in your AWS account from your local machine. Given the differences between these container setups, different approaches are needed to pass in the AWS credentials needed to make these connections. For example, for the recommendations service we can map your local `~./.aws` configuration directory into the container's `/root` directory so the AWS SDK in the container can pick up the credentials it needs. Alternatively, since the products service is packaged from a [scratch image](https://hub.docker.com/_/scratch), credentials must be passed using the `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` environment variables. In this case, rather than setting these variables in `.env` and risk exposing these values, consider setting these three variables in your shell environment. The following command can be used to obtain a session token which can be used to set your environment variables in your shell. +> DynamoDB is one dependency that can be run locally rather than connecting to the real DynamoDB. This makes it easier to run services like the [products](./products) service completely local to you computer. + ```console foo@bar:~$ aws sts get-session-token ``` diff --git a/src/aws-lambda/retaildemostore-lambda-load-products/src/main.go b/src/aws-lambda/retaildemostore-lambda-load-products/src/main.go index b906da45f..a5436fb28 100644 --- a/src/aws-lambda/retaildemostore-lambda-load-products/src/main.go +++ b/src/aws-lambda/retaildemostore-lambda-load-products/src/main.go @@ -23,17 +23,11 @@ import ( "gopkg.in/yaml.v2" ) -type MyEvent struct { - Table string `json:"table"` - Bucket string `json:"bucket"` - File string `json:"file"` - Datatype string `json:"datatype"` -} - // Product Struct // using omitempty as a DynamoDB optimization to create indexes type Product struct { ID string `json:"id" yaml:"id"` + URL string `json:"url" yaml:"url"` SK string `json:"sk" yaml:"sk"` Name string `json:"name" yaml:"name"` Category string `json:"category" yaml:"category"` @@ -43,6 +37,7 @@ type Product struct { Image string `json:"image" yaml:"image"` Featured string `json:"featured,omitempty" yaml:"featured,omitempty"` GenderAffinity string `json:"gender_affinity,omitempty" yaml:"gender_affinity,omitempty"` + CurrentStock int `json:"current_stock" yaml:"current_stock"` } // Products Array @@ -81,6 +76,7 @@ var ( returnstring string ) +// DynamoDBPutItem - upserts item in DDB table func DynamoDBPutItem(item map[string]*dynamodb.AttributeValue, ddbtable string) { input := &dynamodb.PutItemInput{ Item: item, @@ -102,7 +98,7 @@ func loadData(s3bucket, s3file, ddbtable, datatype string) (string, error) { localfile := "/tmp/load.yaml" - log.Println("Attempting to load "+datatype+"file: ", s3bucket, s3file, localfile) + log.Println("Attempting to load "+datatype+" file: ", s3bucket, s3file, localfile) file, err := os.Create(localfile) if err != nil { @@ -158,9 +154,10 @@ func loadData(s3bucket, s3file, ddbtable, datatype string) (string, error) { log.Println("Loaded in ", time.Since(start)) - return "data loaded from" + s3bucket + s3file + ddbtable + datatype + string(time.Since(start)), nil + return "data loaded from" + s3bucket + s3file + ddbtable + datatype + time.Since(start).String(), nil } +// HandleRequest - handles Lambda request func HandleRequest(ctx context.Context, event cfn.Event) (physicalResourceID string, data map[string]interface{}, err error) { Bucket, _ := event.ResourceProperties["Bucket"].(string) File, _ := event.ResourceProperties["File"].(string) diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 229cde888..f52055c43 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -11,6 +11,15 @@ services: - dev-net ports: - "8003:80" + + ddb: + image: amazon/dynamodb-local + container_name: ddb + command: -jar DynamoDBLocal.jar -port 3001 + networks: + - dev-net + ports: + - 3001:3001 orders: container_name: orders @@ -23,6 +32,8 @@ services: products: container_name: products + depends_on: + - ddb environment: - AWS_REGION - AWS_ACCESS_KEY_ID @@ -30,7 +41,9 @@ services: - AWS_SESSION_TOKEN - DDB_TABLE_PRODUCTS - DDB_TABLE_CATEGORIES + - DDB_ENDPOINT_OVERRIDE - IMAGE_ROOT_URL + - WEB_ROOT_URL build: context: ./products networks: diff --git a/src/products/Dockerfile b/src/products/Dockerfile index 037b144d3..72c8470b8 100644 --- a/src/products/Dockerfile +++ b/src/products/Dockerfile @@ -4,6 +4,8 @@ RUN apk add --no-cache git bash RUN go get -u github.com/gorilla/mux RUN go get -u gopkg.in/yaml.v2 RUN go get -u github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute +RUN go get -u github.com/jinzhu/copier +RUN go get -u github.com/google/uuid COPY src/products-service/*.* /src/ COPY src/products-service/data/*.* /src/data/ RUN CGO_ENABLED=0 go build -o /bin/products-service diff --git a/src/products/README.md b/src/products/README.md index 542e5868f..6940db4d9 100644 --- a/src/products/README.md +++ b/src/products/README.md @@ -1,12 +1,12 @@ # Retail Demo Store Products Service -The Products web service provides a RESTful API for retrieving product information. The [Web UI](../web-ui) makes calls to this service when a user is viewing products and categories. +The Products web service provides a RESTful API for retrieving product information. The [Web UI](../web-ui) makes calls to this service when a user is viewing products and categories and the Personalize workshop connects to this service to retrieve product information for building the items dataset. -When deployed to AWS, CodePipeline is used to build and deploy the Products service as a Docker container to Amazon ECS behind an Application Load Balancer. The Products service can also be run locally in a Docker container. This makes it easier to iterate on and test changes locally before commiting. +When deployed to AWS, CodePipeline is used to build and deploy the Products service as a Docker container in Amazon ECS behind an Application Load Balancer. The Products service can also be run locally in a Docker container. This makes it easier to iterate on and test changes locally before commiting. ## Local Development -The Products service can be built and run locally (in Docker) using Docker Compose. See the [local development instructions](../) for details. **From the `../src` directory**, run the following command to build and deploy the service locally. +The Products service can be built and run locally (in Docker) using Docker Compose. See the [local development instructions](../) for details. Since the Products service has a dependency on DynamoDB as its datastore, you can either connect to DynamoDB in your AWS account or run DynamoDB [locally](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html) (default). The [docker-compose.yml](../docker-compose.yml) and default [.env](../.env) is already setup to run DynamoDB locally in Docker. If you want to connect to the real DynamoDB instead, you will need to configure your AWS credentials and comment the `DDB_ENDPOINT_OVERRIDE` environment variable since it is checked first. **From the `../src` directory**, run the following command to build and deploy the service locally. ```console foo@bar:~$ docker-compose up --build products diff --git a/src/products/src/products-service/aws.go b/src/products/src/products-service/aws.go index 075160b9a..f39562f76 100644 --- a/src/products/src/products-service/aws.go +++ b/src/products/src/products-service/aws.go @@ -4,25 +4,41 @@ package main import ( + "log" "os" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/dynamodb" - "github.com/aws/aws-sdk-go/service/ssm" ) -//var sess *session.Session var sess, err = session.NewSession(&aws.Config{}) -// read DynamoDB tables env variable -var ddb_table_products = os.Getenv("DDB_TABLE_PRODUCTS") -var ddb_table_categories = os.Getenv("DDB_TABLE_CATEGORIES") +// DynamoDB table names passed via environment +var ddbTableProducts = os.Getenv("DDB_TABLE_PRODUCTS") +var ddbTableCategories = os.Getenv("DDB_TABLE_CATEGORIES") -var dynamoclient = dynamodb.New(sess) -var ssm_client = ssm.New(sess) +// Allow DDB endpoint to be overridden to support amazon/dynamodb-local +var ddbEndpointOverride = os.Getenv("DDB_ENDPOINT_OVERRIDE") +var runningLocal bool -// Connect Stuff +var dynamoClient *dynamodb.DynamoDB + +// Initialize clients func init() { - + if len(ddbEndpointOverride) > 0 { + runningLocal = true + log.Println("Creating DDB client with endpoint override: ", ddbEndpointOverride) + creds := credentials.NewStaticCredentials("does", "not", "matter") + awsConfig := &aws.Config{ + Credentials: creds, + Region: aws.String("us-east-1"), + Endpoint: aws.String(ddbEndpointOverride), + } + dynamoClient = dynamodb.New(sess, awsConfig) + } else { + runningLocal = false + dynamoClient = dynamodb.New(sess) + } } diff --git a/src/products/src/products-service/category.go b/src/products/src/products-service/category.go index 0ea39eeeb..4d06fcf5a 100644 --- a/src/products/src/products-service/category.go +++ b/src/products/src/products-service/category.go @@ -4,6 +4,7 @@ package main // Category Struct +// IMPORTANT: if you change the shape of this struct, be sure to update the retaildemostore-lambda-load-products Lambda too! type Category struct { ID string `json:"id" yaml:"id"` URL string `json:"url" yaml:"url"` @@ -11,5 +12,8 @@ type Category struct { Image string `json:"image" yaml:"image"` } +// Initialized - indicates if instance has been initialized or not +func (c *Category) Initialized() bool { return c != nil && len(c.ID) > 0 } + // Categories Array type Categories []Category diff --git a/src/products/src/products-service/data/products.yaml b/src/products/src/products-service/data/products.yaml index b803c3a2a..0cc0c7af2 100644 --- a/src/products/src/products-service/data/products.yaml +++ b/src/products/src/products-service/data/products.yaml @@ -6,6 +6,7 @@ price: 109.99 image: "1.jpg" featured: true + current_stock: 15 - id: 2 name: Striped Shirt category: apparel @@ -15,6 +16,7 @@ image: "1.jpg" featured: true gender_affinity: F + current_stock: 12 - id: 3 name: Beard Oil category: beauty @@ -24,6 +26,7 @@ image: "1.jpg" featured: true gender_affinity: M + current_stock: 15 - id: 4 name: Fitness Tracker category: electronics @@ -32,6 +35,7 @@ price: 89.99 image: "1.jpg" featured: true + current_stock: 11 - id: 5 name: Super Knit Sneakers category: footwear @@ -41,6 +45,7 @@ image: "1.jpg" featured: true gender_affinity: M + current_stock: 7 - id: 6 name: Coffee Gift Package category: housewares @@ -49,6 +54,7 @@ price: 39.99 image: "1.jpg" featured: true + current_stock: 13 - id: 7 name: Gold Anchor Womens Bracelet category: jewelry @@ -57,6 +63,7 @@ price: 49.99 image: "1.jpg" gender_affinity: F + current_stock: 6 - id: 8 name: LED Dog Collar category: outdoors @@ -64,6 +71,7 @@ description: Brightly lit collar for your loved pet. price: 29.99 image: "1.jpg" + current_stock: 6 - id: 9 name: Black Leather Shoulder Bag category: accessories @@ -72,6 +80,7 @@ price: 109.99 image: "2.jpg" gender_affinity: F + current_stock: 11 - id: 10 name: Classic T- Shirt category: apparel @@ -79,6 +88,7 @@ description: A classic look for the summer season. price: 9.99 image: "2.jpg" + current_stock: 13 - id: 11 name: Odorless Essential Oil category: beauty @@ -86,6 +96,7 @@ description: This essential oil in liquid and balm forms smooths rough skin without a scent. price: 9.99 image: "2.jpg" + current_stock: 3 - id: 12 name: Lightning Cable category: electronics @@ -93,6 +104,7 @@ description: Charge your phone on the go. price: 89.99 image: "2.jpg" + current_stock: 6 - id: 13 name: LED Leather Hi-Tops category: footwear @@ -101,6 +113,7 @@ price: 129.99 image: "2.jpg" gender_affinity: M + current_stock: 13 - id: 14 name: Coffee Gift Package category: housewares @@ -108,6 +121,7 @@ description: Mug and Coffee gift set combination package. price: 39.99 image: "2.jpg" + current_stock: 13 - id: 15 name: Leather Anchor Mens Bracelet category: jewelry @@ -117,6 +131,7 @@ image: "2.jpg" gender_affinity: M featured: true + current_stock: 7 - id: 16 name: Neon Colored Fishing Lure category: outdoors @@ -124,6 +139,7 @@ description: This lure will be hard to pass up with its bright neon green and yellow colors. price: 9.99 image: "2.jpg" + current_stock: 13 - id: 17 name: Black Women's Purse category: accessories @@ -132,6 +148,7 @@ price: 109.99 image: "3.jpg" gender_affinity: F + current_stock: 5 - id: 18 name: Accent Scarf category: apparel @@ -140,6 +157,7 @@ price: 9.99 image: "3.jpg" gender_affinity: F + current_stock: 12 - id: 19 name: Vitamin E Oil category: beauty @@ -148,6 +166,7 @@ price: 9.99 image: "3.jpg" gender_affinity: M + current_stock: 9 - id: 20 name: Black Bluetooth Portable Speaker category: electronics @@ -155,6 +174,7 @@ description: Blast your tunes anywhere with this Bluetooth wireless speaker. price: 89.99 image: "3.jpg" + current_stock: 2 - id: 21 name: Knit Black Sneakers category: footwear @@ -163,6 +183,7 @@ price: 129.99 image: "3.jpg" gender_affinity: M + current_stock: 7 - id: 22 name: Coffee Gift Package category: housewares @@ -170,6 +191,7 @@ description: Mug and Coffee gift set combination package. price: 39.99 image: "3.jpg" + current_stock: 14 - id: 23 name: Diamond Studded Classic Gold Bracelet category: jewelry @@ -178,6 +200,7 @@ price: 49.99 image: "3.jpg" gender_affinity: F + current_stock: 4 - id: 24 name: Classic Fishing Pole category: outdoors @@ -185,6 +208,7 @@ description: Land a big one with our classic fishing rod and reel. price: 9.99 image: "3.jpg" + current_stock: 2 - id: 25 name: Red Leather Purse category: accessories @@ -194,6 +218,7 @@ image: "4.jpg" gender_affinity: F featured: true + current_stock: 7 - id: 26 name: Blue Shirt category: apparel @@ -203,6 +228,7 @@ image: "4.jpg" gender_affinity: M featured: true + current_stock: 7 - id: 27 name: Bath Bomb category: beauty @@ -212,6 +238,7 @@ image: "10.jpg" gender_affinity: F featured: true + current_stock: 15 - id: 28 name: Blue Mushroom Wireless Speaker category: electronics @@ -219,6 +246,7 @@ description: This funky wireless speaker features a suction cup to temporarily mount on any flat surface. price: 89.99 image: "4.jpg" + current_stock: 11 - id: 29 name: LED Leather Hi-Tops category: footwear @@ -227,6 +255,7 @@ price: 129.99 image: "4.jpg" gender_affinity: M + current_stock: 2 - id: 30 name: Nutmeg Grater category: housewares @@ -234,6 +263,7 @@ description: Grate fresh nutmeg in your coffee and desserts using this compact grater. price: 39.99 image: "4.jpg" + current_stock: 12 - id: 31 name: Black Bead Womens Bracelet category: jewelry @@ -242,6 +272,7 @@ price: 49.99 image: "4.jpg" gender_affinity: F + current_stock: 3 - id: 32 name: Classic Fishing Reel category: outdoors @@ -249,6 +280,7 @@ description: Add this attractive gold and silver fishing reel to an existing pole. price: 29.99 image: "4.jpg" + current_stock: 5 - id: 33 name: Brown Women's Purse category: accessories @@ -257,6 +289,7 @@ price: 109.99 image: "5.jpg" gender_affinity: F + current_stock: 5 - id: 34 name: Classic Bomber Jacket category: apparel @@ -265,6 +298,7 @@ price: 159.99 image: "5.jpg" gender_affinity: M + current_stock: 12 - id: 35 name: Vitamin E Oil category: beauty @@ -272,6 +306,7 @@ description: Removes scars and blemishes and moisturizes skin. price: 9.99 image: "5.jpg" + current_stock: 2 - id: 36 name: Exercise Headphones category: electronics @@ -280,6 +315,7 @@ price: 19.99 image: "5.jpg" featured: true + current_stock: 7 - id: 37 name: Sporty Deck Shoe category: footwear @@ -287,6 +323,7 @@ description: Whether on deck or on the town, these blue deck shoes with brown leather accents will draw a salute. price: 129.99 image: "5.jpg" + current_stock: 8 - id: 38 name: Coffee Gift Package category: housewares @@ -294,6 +331,7 @@ description: Mug and Coffee gift set combination package. price: 39.99 image: "5.jpg" + current_stock: 11 - id: 39 name: Turquoise Womens Necklace category: jewelry @@ -302,6 +340,7 @@ price: 49.99 image: "5.jpg" gender_affinity: F + current_stock: 7 - id: 40 name: Spotted Fishing Lure category: outdoors @@ -309,6 +348,7 @@ description: Land a big one with this blackspotted lure with green bushy tail. price: 9.99 image: "5.jpg" + current_stock: 7 - id: 41 name: Tan Leather Shoulder Bag category: accessories @@ -317,6 +357,7 @@ price: 109.99 image: "6.jpg" gender_affinity: F + current_stock: 13 - id: 42 name: Multi Color Socks category: apparel @@ -325,6 +366,7 @@ price: 9.99 image: "6.jpg" gender_affinity: F + current_stock: 3 - id: 43 name: Beauty Mask category: beauty @@ -332,6 +374,7 @@ description: Remove dirt and revitalize skin with this black kelp mask. price: 9.99 image: "9.jpg" + current_stock: 12 - id: 44 name: Portable Speaker category: electronics @@ -339,6 +382,7 @@ description: Take this compact portable speaker with you when you travel or when you're outdoors. price: 89.99 image: "6.jpg" + current_stock: 10 - id: 45 name: Classic Black Leather Hi-Tops category: footwear @@ -346,6 +390,7 @@ description: Casual or dressy, this black leather hi-top has you covered. price: 129.99 image: "6.jpg" + current_stock: 9 - id: 46 name: Smoothie Blender category: housewares @@ -353,6 +398,7 @@ description: Blend up flavorful smoothies using this attractive blender price: 39.99 image: "6.jpg" + current_stock: 13 - id: 47 name: Gold Bracelt with Multi-Color Tassels category: jewelry @@ -361,6 +407,7 @@ price: 49.99 image: "6.jpg" gender_affinity: F + current_stock: 3 - id: 48 name: Silver Fishing Lure category: outdoors @@ -369,6 +416,7 @@ price: 9.99 image: "6.jpg" featured: true + current_stock: 5 - id: 49 name: Light Brown Leather Lace-Up Boot category: footwear @@ -376,6 +424,7 @@ description: Sturdy enough for the outdoors yet stylish to wear out on the town. price: 89.95 image: "11.jpg" + current_stock: 15 - id: 50 name: Blue Wind Breaker Jacket category: apparel @@ -384,6 +433,7 @@ price: 79.95 image: "25.jpg" gender_affinity: M + current_stock: 11 - id: 51 name: Ceramic Mixing Bowls category: housewares @@ -392,6 +442,7 @@ price: 49.95 image: "7.jpg" featured: true + current_stock: 7 - id: 52 name: Turquoise Globe Earrings category: jewelry @@ -401,6 +452,7 @@ image: "7.jpg" gender_affinity: F featured: true + current_stock: 14 - id: 53 name: Watermelon Flavored Lip Balm category: beauty @@ -409,6 +461,7 @@ price: 9.99 image: "8.jpg" gender_affinity: F + current_stock: 12 - id: 54 name: Over-Ear Headphones category: electronics @@ -416,6 +469,7 @@ description: Immerse youself in listening pleasure with these high-quality over-the-ear headphones. price: 129.99 image: "7.jpg" + current_stock: 8 - id: 55 name: Pink Leather Purse category: accessories @@ -424,6 +478,7 @@ price: 89.99 image: "9.jpg" gender_affinity: F + current_stock: 9 - id: 56 name: Red Fishing Lure category: outdoors @@ -431,6 +486,7 @@ description: Every tackle box needs a silver and red reflective fishing lure. price: 9.99 image: "12.jpg" + current_stock: 9 - id: 57 name: Eyeshadow Palette - Set of 3 Palettes category: beauty @@ -438,6 +494,7 @@ description: Perfect for trialling different shades, each palette contains 4 different shades. price: 145.00 image: "11.jpg" + current_stock: 19 - id: 58 name: Waterproof Eyeliner and Mascara category: beauty @@ -445,6 +502,7 @@ description: In stylish yellow, guaranteed to stay on in sauna and car-wash. price: 27.00 image: "12.jpg" + current_stock: 9 - id: 59 name: Divine Shine - Satin Rose Lipstick Set category: beauty @@ -452,6 +510,7 @@ description: You get not only lipstick, but that other thing with the mirror. Brilliant! price: 35.00 image: "13.jpg" + current_stock: 1 - id: 60 name: Gloss Bomb Universal Lip Luminizer category: beauty @@ -459,6 +518,7 @@ description: 100% guaranteed universal and 100% luminous. price: 19.00 image: "14.jpg" + current_stock: 12 - id: 61 name: 7-in-1 Daily Wear Palette Essentials category: beauty @@ -466,6 +526,7 @@ description: You get a whole bunch of stuff and it is super good quality too. price: 103.00 image: "15.jpg" + current_stock: 2 - id: 62 name: Eye Care Set - Sparkle Gloss Eyes and Lashes category: beauty @@ -473,6 +534,7 @@ description: Your eyes will dazzle and gloss and shine and fizz pop. price: 95.00 image: "16.jpg" + current_stock: 2 - id: 63 name: 15 Piece Makeup Brush Set with Fold Up Leather Case category: beauty @@ -483,6 +545,7 @@ gender_affinity: F image_license: "Free for Commercial Use" link: https://www.pikrepo.com/fmvtc/black-makeup-brush-set-in-bag + current_stock: 2 - id: 64 name: Lovely Blue Mascara category: beauty @@ -493,6 +556,7 @@ gender_affinity: F image_license: CC0 link: https://pxhere.com/en/photo/57398 + current_stock: 2 - id: 65 name: Nail Varnish for Conquerors of Hearts category: beauty @@ -503,6 +567,7 @@ gender_affinity: F image_license: CC0 link: https://www.needpix.com/photo/1711500/nail-varnish-nail-design-cosmetics-manicure-fingernails-paint-toe-nails-fashionable-beauty + current_stock: 3 - id: 66 name: Rose Pink Blush Brush category: beauty @@ -513,6 +578,7 @@ gender_affinity: F image_license: Free for commercial use - just do not resell as a stock photo link: https://pixabay.com/photos/rouge-brush-cosmetics-rouge-brush-2092439/ + current_stock: 4 - id: 67 name: "Subtle and Fresh: Palette of 15 Concealers" category: beauty @@ -523,6 +589,7 @@ gender_affinity: F image_license: Free for commercial use link: https://www.pxfuel.com/en/free-photo-xidzw + current_stock: 6 - id: 68 name: Deep Disguise Concealer category: beauty @@ -533,6 +600,7 @@ gender_affinity: F image_license: CC0 link: https://commons.m.wikimedia.org/wiki/File:Tcsfoundationlogo.jpg + current_stock: 7 - id: 69 name: Classic Bombshell Lipstick category: beauty @@ -543,6 +611,7 @@ gender_affinity: F image_license: Free for commercial use - just do not resell as a stock photo link: https://pixabay.com/photos/lipstick-lips-makeup-cosmetics-5559338/ + current_stock: 11 - id: 70 name: Intense Matte Lipstick category: beauty @@ -553,6 +622,7 @@ gender_affinity: F image_license: Unsplash - free for commercial use link: https://unsplash.com/photos/rjB_1MT6G18 + current_stock: 12 - id: 71 name: 4-Piece Makeup Brush Set category: beauty @@ -563,6 +633,7 @@ gender_affinity: F image_license: Free for commercial use - just do not resell as a stock photo link: https://pixabay.com/photos/maciag-brush-makeup-brushes-5208359/ + current_stock: 4 - id: 72 name: Gangster-Girl Lipstick category: beauty @@ -573,6 +644,7 @@ gender_affinity: F image_license: Free for commercial use link: https://www.pikrepo.com/fyvwn/red-and-gold-lipstick-on-white-background + current_stock: 7 - id: 73 name: Lip Brush category: beauty @@ -583,6 +655,7 @@ gender_affinity: F image_license: Free for commercial use link: https://unsplash.com/photos/qbo7DPBvnV0 + current_stock: 2 - id: 74 name: Precious Cargo Makeup Containers category: beauty @@ -593,6 +666,7 @@ gender_affinity: F image_license: CC0 link: https://pixy.org/5203022/ + current_stock: 20 - id: 75 name: Burn! Lipstick category: beauty @@ -603,6 +677,7 @@ gender_affinity: F image_license: Public domain link: https://www.pikist.com/free-photo-xvcbj + current_stock: 12 - id: 76 name: Grandma's Mascara category: beauty @@ -613,6 +688,7 @@ gender_affinity: F image_license: Free for commercial use link: https://www.pickpik.com/cosmetics-make-up-makeup-beauty-color-eyes-138539 + current_stock: 13 - id: 77 name: Camera Tripod category: electronics @@ -623,6 +699,7 @@ gender_affinity: F image_license: CC0 link: https://www.needpix.com/photo/908201/gorillapod-with-camera-free-pictures-free-photos-free-images-royalty-free + current_stock: 10 - id: 78 name: Nice Stripy Blouse category: apparel @@ -633,6 +710,7 @@ gender_affinity: F image_license: Made by Dae.mn link: + current_stock: 10 - id: 79 name: Pocket Powder Case category: beauty @@ -643,6 +721,7 @@ gender_affinity: F image_license: Public domain link: https://www.pikist.com/free-photo-ixyyz + current_stock: 12 - id: 80 name: Freestanding Glass Makeup Mirror category: housewares @@ -653,6 +732,7 @@ gender_affinity: F image_license: CC0 link: https://www.needpix.com/photo/download/1336308/mirror-small-reflection-decoration-modern-design-frame-round-shop + current_stock: 11 - id: 81 name: Perfect grey sofa category: housewares @@ -662,6 +742,7 @@ image: "9.jpg" image_license: Free for commercial use - just do not resell as a stock photo link: https://pixabay.com/photos/furniture-modern-luxury-indoors-3271762/ + current_stock: 10 - id: 82 name: Perfect cushions category: housewares @@ -671,6 +752,7 @@ image: "10.jpg" image_license: Free for commercial use - just do not resell as a stock photo link: https://pixabay.com/fr/photos/oreillers-patron-lit-int%C3%A9rieur-4326131/ + current_stock: 11 - id: 83 name: Classic coat-rack category: housewares @@ -680,6 +762,7 @@ image: "11.jpg" image_license: Free for commercial use - just do not resell as a stock photo link: https://pixabay.com/photos/hat-coat-rack-wing-pet-fashion-2176837/ + current_stock: 3 - id: 84 name: Spare bookshelves category: housewares @@ -689,3 +772,4 @@ image: "12.jpg" image_license: CC0 link: https://www.needpix.com/photo/download/1856333/shelf-white-living-world-bookshelf-books-bookshelves-set-up-living-room-book + current_stock: 3 diff --git a/src/products/src/products-service/handlers.go b/src/products/src/products-service/handlers.go index 89d7f0c08..fd513bde8 100644 --- a/src/products/src/products-service/handlers.go +++ b/src/products/src/products-service/handlers.go @@ -4,51 +4,79 @@ package main import ( - "os" - "log" "encoding/json" + "errors" "fmt" + "io" + "io/ioutil" + "log" "net/http" + "os" "github.com/gorilla/mux" "strconv" ) -var image_root_url = os.Getenv("IMAGE_ROOT_URL") +var imageRootURL = os.Getenv("IMAGE_ROOT_URL") +var missingImageFile = "product_image_coming_soon.png" -func FullyQualifyCategoryImageUrl(c Category) Category { - log.Println("Fully qualifying category image URL") - c.Image = image_root_url + c.Name + "/" + c.Image - return c +// initResponse +func initResponse(w *http.ResponseWriter) { + (*w).Header().Set("Access-Control-Allow-Origin", "*") + (*w).Header().Set("Content-Type", "application/json; charset=UTF-8") } -func FullyQualifyCategoryImageUrls(categories Categories) Categories { - log.Println("Fully qualifying category image URLs") - ret := make([]Category, len(categories)) +func fullyQualifyImageURLs(r *http.Request) bool { + param := r.URL.Query().Get("fullyQualifyImageUrls") + if len(param) == 0 { + param = "1" + } + + fullyQualify, _ := strconv.ParseBool(param) + return fullyQualify +} - for i, c := range categories { - c.Image = image_root_url + c.Name + "/" + c.Image - ret[i] = c +// fullyQualifyCategoryImageURL - fully qualifies image URL for a category +func fullyQualifyCategoryImageURL(r *http.Request, c *Category) { + if fullyQualifyImageURLs(r) { + if len(c.Image) > 0 && c.Image != missingImageFile { + c.Image = imageRootURL + c.Name + "/" + c.Image + } else { + c.Image = imageRootURL + missingImageFile + } + } else if len(c.Image) == 0 || c.Image == missingImageFile { + c.Image = missingImageFile } - return ret } -func FullyQualifyProductImageUrl(p Product) Product { - log.Println("Fully qualifying product image URL") - p.Image = image_root_url + p.Category + "/" + p.Image - return p +// fullyQualifyCategoryImageURLs - fully qualifies image URL for categories +func fullyQualifyCategoryImageURLs(r *http.Request, categories *Categories) { + for i := range *categories { + category := &((*categories)[i]) + fullyQualifyCategoryImageURL(r, category) + } } -func FullyQualifyProductImageUrls(products Products) Products { - log.Println("Fully qualifying product image URLs") - ret := make([]Product, len(products)) +// fullyQualifyProductImageURL - fully qualifies image URL for a product +func fullyQualifyProductImageURL(r *http.Request, p *Product) { + if fullyQualifyImageURLs(r) { + if len(p.Image) > 0 && p.Image != missingImageFile { + p.Image = imageRootURL + p.Category + "/" + p.Image + } else { + p.Image = imageRootURL + missingImageFile + } + } else if len(p.Image) == 0 || p.Image == missingImageFile { + p.Image = missingImageFile + } +} - for i, p := range products { - p.Image = image_root_url + p.Category + "/" + p.Image - ret[i] = p +// fullyQualifyProductImageURLs - fully qualifies image URLs for all products +func fullyQualifyProductImageURLs(r *http.Request, products *Products) { + for i := range *products { + product := &((*products)[i]) + fullyQualifyProductImageURL(r, product) } - return ret } // Index Handler @@ -58,18 +86,11 @@ func Index(w http.ResponseWriter, r *http.Request) { // ProductIndex Handler func ProductIndex(w http.ResponseWriter, r *http.Request) { + initResponse(&w) - enableCors(&w) - - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(http.StatusOK) + ret := RepoFindALLProducts() - ret := RepoFindALLProduct() - - fullyQualify, _ := strconv.ParseBool(r.URL.Query().Get("fullyQualifyImageUrls")) - if fullyQualify { - ret = FullyQualifyProductImageUrls(ret) - } + fullyQualifyProductImageURLs(r, &ret) if err := json.NewEncoder(w).Encode(ret); err != nil { panic(err) @@ -78,18 +99,11 @@ func ProductIndex(w http.ResponseWriter, r *http.Request) { // CategoryIndex Handler func CategoryIndex(w http.ResponseWriter, r *http.Request) { - - enableCors(&w) - - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(http.StatusOK) + initResponse(&w) ret := RepoFindALLCategories() - fullyQualify, _ := strconv.ParseBool(r.URL.Query().Get("fullyQualifyImageUrls")) - if fullyQualify { - ret = FullyQualifyCategoryImageUrls(ret) - } + fullyQualifyCategoryImageURLs(r, &ret) // TODO if err := json.NewEncoder(w).Encode(ret); err != nil { @@ -99,23 +113,19 @@ func CategoryIndex(w http.ResponseWriter, r *http.Request) { // ProductShow Handler func ProductShow(w http.ResponseWriter, r *http.Request) { - - enableCors(&w) + initResponse(&w) vars := mux.Vars(r) - productID, err := strconv.Atoi(vars["productID"]) - - if err != nil { - panic(err) - } - ret := RepoFindProduct(productID) + ret := RepoFindProduct(vars["productID"]) - fullyQualify, _ := strconv.ParseBool(r.URL.Query().Get("fullyQualifyImageUrls")) - if fullyQualify { - ret = FullyQualifyProductImageUrl(ret) + if !ret.Initialized() { + http.Error(w, "Product not found", http.StatusNotFound) + return } + fullyQualifyProductImageURL(r, &ret) + if err := json.NewEncoder(w).Encode(ret); err != nil { panic(err) } @@ -123,18 +133,34 @@ func ProductShow(w http.ResponseWriter, r *http.Request) { // CategoryShow Handler func CategoryShow(w http.ResponseWriter, r *http.Request) { + initResponse(&w) - enableCors(&w) + vars := mux.Vars(r) + + ret := RepoFindCategory(vars["categoryID"]) + + if !ret.Initialized() { + http.Error(w, "Category not found", http.StatusNotFound) + return + } + + fullyQualifyCategoryImageURL(r, &ret) + + if err := json.NewEncoder(w).Encode(ret); err != nil { + panic(err) + } +} + +// ProductInCategory Handler +func ProductInCategory(w http.ResponseWriter, r *http.Request) { + initResponse(&w) vars := mux.Vars(r) categoryName := vars["categoryName"] ret := RepoFindProductByCategory(categoryName) - fullyQualify, _ := strconv.ParseBool(r.URL.Query().Get("fullyQualifyImageUrls")) - if fullyQualify { - ret = FullyQualifyProductImageUrls(ret) - } + fullyQualifyProductImageURLs(r, &ret) if err := json.NewEncoder(w).Encode(ret); err != nil { panic(err) @@ -143,22 +169,185 @@ func CategoryShow(w http.ResponseWriter, r *http.Request) { // ProductFeatured Handler func ProductFeatured(w http.ResponseWriter, r *http.Request) { - - enableCors(&w) + initResponse(&w) ret := RepoFindFeatured() - fullyQualify, _ := strconv.ParseBool(r.URL.Query().Get("fullyQualifyImageUrls")) - if fullyQualify { - ret = FullyQualifyProductImageUrls(ret) - } + fullyQualifyProductImageURLs(r, &ret) if err := json.NewEncoder(w).Encode(ret); err != nil { panic(err) } } -// enableCors -func enableCors(w *http.ResponseWriter) { - (*w).Header().Set("Access-Control-Allow-Origin", "*") +func validateProduct(product *Product) error { + if len(product.Name) == 0 { + return errors.New("Product name is required") + } + + if product.Price < 0 { + return errors.New("Product price cannot be a negative value") + } + + if product.CurrentStock < 0 { + return errors.New("Product current stock cannot be a negative value") + } + + if len(product.Category) > 0 { + categories := RepoFindCategoriesByName(product.Category) + if len(categories) == 0 { + return errors.New("Invalid product category; does not exist") + } + } + + return nil +} + +// UpdateProduct - updates a product +func UpdateProduct(w http.ResponseWriter, r *http.Request) { + initResponse(&w) + + vars := mux.Vars(r) + + print(vars) + var product Product + + body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) + if err != nil { + panic(err) + } + if err := r.Body.Close(); err != nil { + panic(err) + } + if err := json.Unmarshal(body, &product); err != nil { + http.Error(w, "Invalid request payload", http.StatusUnprocessableEntity) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + } + + if err := validateProduct(&product); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + existingProduct := RepoFindProduct(vars["productID"]) + if !existingProduct.Initialized() { + // Existing product does not exist + http.Error(w, "Product not found", http.StatusNotFound) + return + } + + if err := RepoUpdateProduct(&existingProduct, &product); err != nil { + http.Error(w, "Internal error updating product", http.StatusInternalServerError) + return + } + + fullyQualifyProductImageURL(r, &product) + + if err := json.NewEncoder(w).Encode(product); err != nil { + panic(err) + } +} + +// UpdateInventory - updates stock quantity for one item +func UpdateInventory(w http.ResponseWriter, r *http.Request) { + initResponse(&w) + + vars := mux.Vars(r) + + var inventory Inventory + + body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) + if err != nil { + panic(err) + } + if err := r.Body.Close(); err != nil { + panic(err) + } + log.Println("UpdateInventory Body ", body) + + if err := json.Unmarshal(body, &inventory); err != nil { + http.Error(w, "Invalid request payload", http.StatusUnprocessableEntity) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + } + + log.Println("UpdateInventory --> ", inventory) + + // Get the current product + product := RepoFindProduct(vars["productID"]) + if !product.Initialized() { + // Existing product does not exist + http.Error(w, "Product not found", http.StatusNotFound) + return + } + + if err := RepoUpdateInventoryDelta(&product, inventory.StockDelta); err != nil { + panic(err) + } + + fullyQualifyProductImageURL(r, &product) + + if err := json.NewEncoder(w).Encode(product); err != nil { + panic(err) + } +} + +// NewProduct - creates a new Product +func NewProduct(w http.ResponseWriter, r *http.Request) { + initResponse(&w) + + var product Product + body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1048576)) + if err != nil { + panic(err) + } + if err := r.Body.Close(); err != nil { + panic(err) + } + if err := json.Unmarshal(body, &product); err != nil { + http.Error(w, "Invalid request payload", http.StatusUnprocessableEntity) + if err := json.NewEncoder(w).Encode(err); err != nil { + panic(err) + } + } + + log.Println("NewProduct ", product) + + if err := validateProduct(&product); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + if err := RepoNewProduct(&product); err != nil { + http.Error(w, "Internal error creating product", http.StatusInternalServerError) + return + } + + fullyQualifyProductImageURL(r, &product) + + if err := json.NewEncoder(w).Encode(product); err != nil { + panic(err) + } +} + +// DeleteProduct - deletes a single product +func DeleteProduct(w http.ResponseWriter, r *http.Request) { + initResponse(&w) + + vars := mux.Vars(r) + + // Get the current product + product := RepoFindProduct(vars["productID"]) + if !product.Initialized() { + // Existing product does not exist + http.Error(w, "Product not found", http.StatusNotFound) + return + } + + if err := RepoDeleteProduct(&product); err != nil { + http.Error(w, "Internal error deleting product", http.StatusInternalServerError) + } } diff --git a/src/products/src/products-service/localdev.go b/src/products/src/products-service/localdev.go new file mode 100644 index 000000000..7845ff23b --- /dev/null +++ b/src/products/src/products-service/localdev.go @@ -0,0 +1,306 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 + +/* + * Supports developing locally where DDB is running locally using + * amazon/dynamodb-local (Docker) or local DynamoDB. + * https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html + */ + +package main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" + yaml "gopkg.in/yaml.v2" +) + +func init() { + if runningLocal { + waitForLocalDDB() + loadData() + } +} + +// waitForLocalDDB - since local DDB can take a couple seconds to startup, we give it some time. +func waitForLocalDDB() { + log.Println("Verifying that local DynamoDB is running at: ", ddbEndpointOverride) + + ddbRunning := false + + for i := 0; i < 5; i++ { + resp, _ := http.Get(ddbEndpointOverride) + + if resp != nil && resp.StatusCode >= 200 { + log.Println("Received HTTP response from local DynamoDB service!") + ddbRunning = true + break + } + + log.Println("Local DynamoDB service is not ready yet... pausing before trying again") + time.Sleep(2 * time.Second) + } + + if !ddbRunning { + log.Panic("Local DynamoDB service not responding; verify that your docker-compose .env file is setup correctly") + } +} + +func loadData() { + err := createProductsTable() + if err != nil { + log.Panic("Unable to create products table.") + } + + err = loadProducts("/bin/data/products.yaml") + if err != nil { + log.Panic("Unable to load products file.") + } + + err = createCategoriesTable() + if err != nil { + log.Panic("Unable to create categories table.") + } + + err = loadCategories("/bin/data/categories.yaml") + if err != nil { + log.Panic("Unable to load category file.") + } + + log.Println("Successfully loaded product and category data into DDB") +} + +func createProductsTable() error { + log.Println("Creating products table: ", ddbTableProducts) + + // Table definition mapped from /aws/cloudformation-templates/base/tables.yaml + input := &dynamodb.CreateTableInput{ + AttributeDefinitions: []*dynamodb.AttributeDefinition{ + { + AttributeName: aws.String("id"), + AttributeType: aws.String("S"), + }, + { + AttributeName: aws.String("category"), + AttributeType: aws.String("S"), + }, + { + AttributeName: aws.String("featured"), + AttributeType: aws.String("S"), + }, + }, + KeySchema: []*dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("id"), + KeyType: aws.String("HASH"), + }, + { + AttributeName: aws.String("category"), + KeyType: aws.String("RANGE"), + }, + }, + BillingMode: aws.String("PAY_PER_REQUEST"), + LocalSecondaryIndexes: []*dynamodb.LocalSecondaryIndex{ + { + IndexName: aws.String("id-featured-index"), + KeySchema: []*dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("id"), + KeyType: aws.String("HASH"), + }, + { + AttributeName: aws.String("featured"), + KeyType: aws.String("RANGE"), + }, + }, + Projection: &dynamodb.Projection{ + ProjectionType: aws.String("ALL"), + }, + }, + }, + GlobalSecondaryIndexes: []*dynamodb.GlobalSecondaryIndex{ + { + IndexName: aws.String("category-index"), + KeySchema: []*dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("category"), + KeyType: aws.String("HASH"), + }, + }, + Projection: &dynamodb.Projection{ + ProjectionType: aws.String("ALL"), + }, + }, + }, + TableName: aws.String(ddbTableProducts), + } + + _, err := dynamoClient.CreateTable(input) + if err != nil { + log.Println("Error creating products table: ", ddbTableProducts) + + if aerr, ok := err.(awserr.Error); ok { + if aerr.Code() == dynamodb.ErrCodeResourceInUseException { + log.Println("Table already exists; continuing") + err = nil + } else { + log.Println(err.Error()) + } + } else { + log.Println(err.Error()) + } + } + + return err +} + +func loadProducts(filename string) error { + start := time.Now() + + log.Println("Loading products from file: ", filename) + + var r Products + + bytes, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + err = yaml.Unmarshal(bytes, &r) + if err != nil { + return err + } + + for _, item := range r { + + av, err := dynamodbattribute.MarshalMap(item) + + if err != nil { + return err + } + + input := &dynamodb.PutItemInput{ + Item: av, + TableName: aws.String(ddbTableProducts), + } + + _, err = dynamoClient.PutItem(input) + if err != nil { + fmt.Println("Got error calling PutItem:") + fmt.Println(err.Error()) + + } + + } + + log.Println("Products loaded in ", time.Since(start)) + + return nil +} + +func createCategoriesTable() error { + log.Println("Creating categories table: ", ddbTableCategories) + + // Table definition mapped from /aws/cloudformation-templates/base/tables.yaml + input := &dynamodb.CreateTableInput{ + AttributeDefinitions: []*dynamodb.AttributeDefinition{ + { + AttributeName: aws.String("id"), + AttributeType: aws.String("S"), + }, + { + AttributeName: aws.String("name"), + AttributeType: aws.String("S"), + }, + }, + KeySchema: []*dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("id"), + KeyType: aws.String("HASH"), + }, + }, + BillingMode: aws.String("PAY_PER_REQUEST"), + GlobalSecondaryIndexes: []*dynamodb.GlobalSecondaryIndex{ + { + IndexName: aws.String("name-index"), + KeySchema: []*dynamodb.KeySchemaElement{ + { + AttributeName: aws.String("name"), + KeyType: aws.String("HASH"), + }, + }, + Projection: &dynamodb.Projection{ + ProjectionType: aws.String("ALL"), + }, + }, + }, + TableName: aws.String(ddbTableCategories), + } + + _, err := dynamoClient.CreateTable(input) + if err != nil { + log.Println("Error creating categories table: ", ddbTableCategories) + + if aerr, ok := err.(awserr.Error); ok { + if aerr.Code() == dynamodb.ErrCodeResourceInUseException { + log.Println("Table already exists; continuing") + err = nil + } else { + log.Println(err.Error()) + } + } else { + log.Println(err.Error()) + } + } + + return err +} + +func loadCategories(filename string) error { + + start := time.Now() + + log.Println("Loading categories from file: ", filename) + + var r Categories + + bytes, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + err = yaml.Unmarshal(bytes, &r) + if err != nil { + return err + } + for _, item := range r { + av, err := dynamodbattribute.MarshalMap(item) + + if err != nil { + return err + } + + input := &dynamodb.PutItemInput{ + Item: av, + TableName: aws.String(ddbTableCategories), + } + + _, err = dynamoClient.PutItem(input) + if err != nil { + fmt.Println("Got error calling PutItem:") + fmt.Println(err.Error()) + } + } + + log.Println("Categories loaded in ", time.Since(start)) + + return nil +} diff --git a/src/products/src/products-service/product.go b/src/products/src/products-service/product.go index e8f07c42c..e38f9326d 100644 --- a/src/products/src/products-service/product.go +++ b/src/products/src/products-service/product.go @@ -5,9 +5,10 @@ package main // Product Struct // using omitempty as a DynamoDB optimization to create indexes +// IMPORTANT: if you change the shape of this struct, be sure to update the retaildemostore-lambda-load-products Lambda too! type Product struct { - ID string `json:"id" yaml:"id"` - URL string `json:"url" yaml:"url"` + ID string `json:"id" yaml:"id" copier:"-"` + URL string `json:"url" yaml:"url" copier:"-"` SK string `json:"sk" yaml:"sk"` Name string `json:"name" yaml:"name"` Category string `json:"category" yaml:"category"` @@ -17,7 +18,16 @@ type Product struct { Image string `json:"image" yaml:"image"` Featured string `json:"featured,omitempty" yaml:"featured,omitempty"` GenderAffinity string `json:"gender_affinity,omitempty" yaml:"gender_affinity,omitempty"` + CurrentStock int `json:"current_stock" yaml:"current_stock"` } +// Initialized - indicates if instance has been initialized or not +func (p *Product) Initialized() bool { return p != nil && len(p.ID) > 0 } + // Products Array type Products []Product + +// Inventory Struct +type Inventory struct { + StockDelta int `json:"stock_delta" yaml:"stock_delta"` +} diff --git a/src/products/src/products-service/repository.go b/src/products/src/products-service/repository.go index ab14f935b..57e2d8260 100644 --- a/src/products/src/products-service/repository.go +++ b/src/products/src/products-service/repository.go @@ -5,182 +5,172 @@ package main import ( "fmt" - "io/ioutil" "log" "os" "strconv" - "time" + "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" "github.com/aws/aws-sdk-go/service/dynamodb/expression" - - yaml "gopkg.in/yaml.v2" + guuuid "github.com/google/uuid" + "github.com/jinzhu/copier" ) -var products Products -var categories Categories -var exp_true bool = true - // Root/base URL to use when building fully-qualified URLs to product detail view. -var web_root_url = os.Getenv("WEB_ROOT_URL") - -func init() { -} - -func loadData() { +var webRootURL = os.Getenv("WEB_ROOT_URL") - err := loadProducts("/bin/data/products.yaml") - if err != nil { - log.Panic("Unable to load products file.") +func setProductURL(p *Product) { + if len(webRootURL) > 0 { + p.URL = webRootURL + "/#/product/" + p.ID } +} - err = loadCategories("/bin/data/categories.yaml") - if err != nil { - log.Panic("Unable to load category file.") +func setCategoryURL(c *Category) { + if len(webRootURL) > 0 && len(c.Name) > 0 { + c.URL = webRootURL + "/#/category/" + c.Name } } -func loadProducts(filename string) error { - start := time.Now() +// RepoFindProduct Function +func RepoFindProduct(id string) Product { + var product Product - log.Println("Attempting to load products file: ", filename) + id = strings.ToLower(id) - var r Products + log.Println("RepoFindProduct: ", id, ddbTableProducts) - bytes, err := ioutil.ReadFile(filename) - if err != nil { - return err + keycond := expression.Key("id").Equal(expression.Value(id)) + expr, err := expression.NewBuilder().WithKeyCondition(keycond).Build() + // Build the query input parameters + params := &dynamodb.QueryInput{ + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + KeyConditionExpression: expr.KeyCondition(), + ProjectionExpression: expr.Projection(), + TableName: aws.String(ddbTableProducts), } + // Make the DynamoDB Query API call + result, err := dynamoClient.Query(params) - err = yaml.Unmarshal(bytes, &r) if err != nil { - return err + log.Println("get item error " + string(err.Error())) + return product } - for _, item := range r { - - av, err := dynamodbattribute.MarshalMap(item) + if len(result.Items) > 0 { + err = dynamodbattribute.UnmarshalMap(result.Items[0], &product) if err != nil { - return err + panic(fmt.Sprintf("Failed to unmarshal Record, %v", err)) } - input := &dynamodb.PutItemInput{ - Item: av, - TableName: aws.String(ddb_table_products), - } - - _, err = dynamoclient.PutItem(input) - if err != nil { - fmt.Println("Got error calling PutItem:") - fmt.Println(err.Error()) - - } + setProductURL(&product) + log.Println("RepoFindProduct returning: ", product.Name, product.Category) } - log.Println("Products loaded in ", time.Since(start)) - return nil + return product } -func loadCategories(filename string) error { +// RepoFindCategory Function +func RepoFindCategory(id string) Category { + var category Category - start := time.Now() + id = strings.ToLower(id) - log.Println("Attempting to load categories file: ", filename) + log.Println("RepoFindCategory: ", id, ddbTableCategories) - var r Categories - - bytes, err := ioutil.ReadFile(filename) - if err != nil { - return err + keycond := expression.Key("id").Equal(expression.Value(id)) + expr, err := expression.NewBuilder().WithKeyCondition(keycond).Build() + // Build the query input parameters + params := &dynamodb.QueryInput{ + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + KeyConditionExpression: expr.KeyCondition(), + ProjectionExpression: expr.Projection(), + TableName: aws.String(ddbTableCategories), } + // Make the DynamoDB Query API call + result, err := dynamoClient.Query(params) - err = yaml.Unmarshal(bytes, &r) if err != nil { - return err + log.Println("get item error " + string(err.Error())) + return category } - for _, item := range r { - av, err := dynamodbattribute.MarshalMap(item) - if err != nil { - return err - } + if len(result.Items) > 0 { + err = dynamodbattribute.UnmarshalMap(result.Items[0], &category) - input := &dynamodb.PutItemInput{ - Item: av, - TableName: aws.String(ddb_table_categories), - } - - _, err = dynamoclient.PutItem(input) if err != nil { - fmt.Println("Got error calling PutItem:") - fmt.Println(err.Error()) - + panic(fmt.Sprintf("Failed to unmarshal Record, %v", err)) } - } - - log.Println("Categories loaded in ", time.Since(start)) + setCategoryURL(&category) - return nil -} - -func SetProductURL(p Product) Product { - if len(web_root_url) > 0 { - p.URL = web_root_url + "/#/product/" + p.ID + log.Println("RepoFindCategory returning: ", category.Name) } - return p + return category } -func SetCategoryURL(c Category) Category { - if len(web_root_url) > 0 { - c.URL = web_root_url + "/#/category/" + c.Name - } - - return c -} +// RepoFindCategoriesByName Function +func RepoFindCategoriesByName(name string) Categories { + var categories Categories -// RepoFindProduct Function -func RepoFindProduct(id int) Product { + log.Println("RepoFindCategoriesByName: ", name, ddbTableCategories) - var product Product + keycond := expression.Key("name").Equal(expression.Value(name)) + proj := expression.NamesList(expression.Name("id"), + expression.Name("name"), + expression.Name("image")) + expr, err := expression.NewBuilder().WithKeyCondition(keycond).WithProjection(proj).Build() - log.Println("RepoFindProduct: ", strconv.Itoa(id), ddb_table_products) + if err != nil { + log.Println("Got error building expression:") + log.Println(err.Error()) + } - keycond := expression.Key("id").Equal(expression.Value(strconv.Itoa(id))) - expr, err := expression.NewBuilder().WithKeyCondition(keycond).Build() // Build the query input parameters params := &dynamodb.QueryInput{ ExpressionAttributeNames: expr.Names(), ExpressionAttributeValues: expr.Values(), KeyConditionExpression: expr.KeyCondition(), ProjectionExpression: expr.Projection(), - TableName: aws.String(ddb_table_products), + TableName: aws.String(ddbTableCategories), + IndexName: aws.String("name-index"), } // Make the DynamoDB Query API call - result, err := dynamoclient.Query(params) + result, err := dynamoClient.Query(params) if err != nil { - log.Println("get item error" + string(err.Error())) - return product + log.Println("Got error QUERY expression:") + log.Println(err.Error()) } - err = dynamodbattribute.UnmarshalMap(result.Items[0], &product) + log.Println("RepoFindCategoriesByName / items found = ", len(result.Items)) - if err != nil { - panic(fmt.Sprintf("Failed to unmarshal Record, %v", err)) - } + for _, i := range result.Items { + item := Category{} - product = SetProductURL(product) + err = dynamodbattribute.UnmarshalMap(i, &item) - log.Println("RepoFindProduct returning: ", product.Name, product.Category) + if err != nil { + log.Println("Got error unmarshalling:") + log.Println(err.Error()) + } else { + setCategoryURL(&item) + } - // return the uniq item returned. - return product + categories = append(categories, item) + } + + if len(result.Items) == 0 { + categories = make([]Category, 0) + } + + return categories } // RepoFindProductByCategory Function @@ -198,7 +188,8 @@ func RepoFindProductByCategory(category string) Products { expression.Name("style"), expression.Name("description"), expression.Name("price"), - expression.Name("gender_affinity")) + expression.Name("gender_affinity"), + expression.Name("current_stock")) expr, err := expression.NewBuilder().WithKeyCondition(keycond).WithProjection(proj).Build() if err != nil { @@ -212,11 +203,11 @@ func RepoFindProductByCategory(category string) Products { ExpressionAttributeValues: expr.Values(), KeyConditionExpression: expr.KeyCondition(), ProjectionExpression: expr.Projection(), - TableName: aws.String(ddb_table_products), + TableName: aws.String(ddbTableProducts), IndexName: aws.String("category-index"), } // Make the DynamoDB Query API call - result, err := dynamoclient.Query(params) + result, err := dynamoClient.Query(params) if err != nil { log.Println("Got error QUERY expression:") @@ -234,12 +225,16 @@ func RepoFindProductByCategory(category string) Products { log.Println("Got error unmarshalling:") log.Println(err.Error()) } else { - item = SetProductURL(item) + setProductURL(&item) } f = append(f, item) } + if len(result.Items) == 0 { + f = make([]Product, 0) + } + return f } @@ -265,11 +260,11 @@ func RepoFindFeatured() Products { ExpressionAttributeValues: expr.Values(), FilterExpression: expr.Filter(), ProjectionExpression: expr.Projection(), - TableName: aws.String(ddb_table_products), + TableName: aws.String(ddbTableProducts), IndexName: aws.String("id-featured-index"), } // Make the DynamoDB Query API call - result, err := dynamoclient.Scan(params) + result, err := dynamoClient.Scan(params) if err != nil { log.Println("Got error scan expression:") @@ -287,17 +282,22 @@ func RepoFindFeatured() Products { log.Println("Got error unmarshalling:") log.Println(err.Error()) } else { - item = SetProductURL(item) + setProductURL(&item) } f = append(f, item) } + if len(result.Items) == 0 { + f = make([]Product, 0) + } + return f } -// TODO: implement some caching +// RepoFindALLCategories - loads all categories func RepoFindALLCategories() Categories { + // TODO: implement some caching log.Println("RepoFindALLCategories: ") @@ -305,10 +305,10 @@ func RepoFindALLCategories() Categories { // Build the query input parameters params := &dynamodb.ScanInput{ - TableName: aws.String(ddb_table_categories), + TableName: aws.String(ddbTableCategories), } // Make the DynamoDB Query API call - result, err := dynamoclient.Scan(params) + result, err := dynamoClient.Scan(params) if err != nil { log.Println("Got error scan expression:") @@ -326,35 +326,39 @@ func RepoFindALLCategories() Categories { log.Println("Got error unmarshalling:") log.Println(err.Error()) } else { - item = SetCategoryURL(item) + setCategoryURL(&item) } f = append(f, item) } + if len(result.Items) == 0 { + f = make([]Category, 0) + } + return f } -// RepoFindALLProduct Function -func RepoFindALLProduct() Products { +// RepoFindALLProducts Function +func RepoFindALLProducts() Products { - log.Println("RepoFindALLProduct: ") + log.Println("RepoFindALLProducts") var f Products // Build the query input parameters params := &dynamodb.ScanInput{ - TableName: aws.String(ddb_table_products), + TableName: aws.String(ddbTableProducts), } // Make the DynamoDB Query API call - result, err := dynamoclient.Scan(params) + result, err := dynamoClient.Scan(params) if err != nil { log.Println("Got error scan expression:") log.Println(err.Error()) } - log.Println("RepoFindALLProduct / items found = ", len(result.Items)) + log.Println("RepoFindALLProducts / items found = ", len(result.Items)) for _, i := range result.Items { item := Product{} @@ -365,11 +369,148 @@ func RepoFindALLProduct() Products { log.Println("Got error unmarshalling:") log.Println(err.Error()) } else { - item = SetProductURL(item) + setProductURL(&item) } f = append(f, item) } + if len(result.Items) == 0 { + f = make([]Product, 0) + } + return f } + +// RepoUpdateProduct - updates an existing product +func RepoUpdateProduct(existingProduct *Product, updatedProduct *Product) error { + updatedProduct.ID = existingProduct.ID // Ensure we're not changing product ID. + updatedProduct.URL = "" // URL is generated so ignore if specified + log.Printf("UpdateProduct from %#v to %#v", existingProduct, updatedProduct) + + copier.Copy(existingProduct, updatedProduct) + log.Printf("after Copier %#v", updatedProduct) + + av, err := dynamodbattribute.MarshalMap(updatedProduct) + + if err != nil { + fmt.Println("Got error calling dynamodbattribute MarshalMap:") + fmt.Println(err.Error()) + return err + } + + input := &dynamodb.PutItemInput{ + Item: av, + TableName: aws.String(ddbTableProducts), + } + + _, err = dynamoClient.PutItem(input) + if err != nil { + fmt.Println("Got error calling PutItem:") + fmt.Println(err.Error()) + } + + setProductURL(updatedProduct) + + return err +} + +// RepoUpdateInventoryDelta - updates a product's current inventory +func RepoUpdateInventoryDelta(product *Product, stockDelta int) error { + + log.Printf("RepoUpdateInventoryDelta for product %#v, delta: %v", product, stockDelta) + + if product.CurrentStock+stockDelta < 0 { + // ensuring we don't get negative stocks, just down to zero stock + // FUTURE: allow backorders via negative current stock? + stockDelta = -product.CurrentStock + } + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ + ":stock_delta": { + N: aws.String(strconv.Itoa(stockDelta)), + }, + ":currstock": { + N: aws.String(strconv.Itoa(product.CurrentStock)), + }, + }, + TableName: aws.String(ddbTableProducts), + Key: map[string]*dynamodb.AttributeValue{ + "id": { + S: aws.String(product.ID), + }, + "category": { + S: aws.String(product.Category), + }, + }, + ReturnValues: aws.String("UPDATED_NEW"), + UpdateExpression: aws.String("set current_stock = current_stock + :stock_delta"), + ConditionExpression: aws.String("current_stock = :currstock"), + } + + _, err = dynamoClient.UpdateItem(input) + if err != nil { + fmt.Println("Got error calling UpdateItem:") + fmt.Println(err.Error()) + } else { + product.CurrentStock = product.CurrentStock + stockDelta + } + + return err +} + +// RepoNewProduct - initializes and persists new product +func RepoNewProduct(product *Product) error { + log.Printf("RepoNewProduct --> %#v", product) + + product.ID = strings.ToLower(guuuid.New().String()) + av, err := dynamodbattribute.MarshalMap(product) + + if err != nil { + fmt.Println("Got error calling dynamodbattribute MarshalMap:") + fmt.Println(err.Error()) + return err + } + + input := &dynamodb.PutItemInput{ + Item: av, + TableName: aws.String(ddbTableProducts), + } + + _, err = dynamoClient.PutItem(input) + if err != nil { + fmt.Println("Got error calling PutItem:") + fmt.Println(err.Error()) + } + + setProductURL(product) + + return err +} + +// RepoDeleteProduct - deletes a single product +func RepoDeleteProduct(product *Product) error { + log.Println("Deleting product: ", product) + + input := &dynamodb.DeleteItemInput{ + Key: map[string]*dynamodb.AttributeValue{ + "id": { + S: aws.String(product.ID), + }, + "category": { + S: aws.String(product.Category), + }, + }, + TableName: aws.String(ddbTableProducts), + } + + _, err := dynamoClient.DeleteItem(input) + + if err != nil { + fmt.Println("Got error calling DeleteItem:") + fmt.Println(err.Error()) + } + + return err +} diff --git a/src/products/src/products-service/routes.go b/src/products/src/products-service/routes.go index 28373d189..1820cbb00 100644 --- a/src/products/src/products-service/routes.go +++ b/src/products/src/products-service/routes.go @@ -29,12 +29,6 @@ var routes = Routes{ "/products/all", ProductIndex, }, - Route{ - "CategoryIndex", - "GET", - "/categories/all", - CategoryIndex, - }, Route{ "ProductShow", "GET", @@ -48,9 +42,45 @@ var routes = Routes{ ProductFeatured, }, Route{ - "CategoryShow", + "ProductInCategory", "GET", "/products/category/{categoryName}", + ProductInCategory, + }, + Route{ + "ProductUpdate", + "PUT", + "/products/id/{productID}", + UpdateProduct, + }, + Route{ + "ProductDelete", + "DELETE", + "/products/id/{productID}", + DeleteProduct, + }, + Route{ + "NewProduct", + "POST", + "/products", + NewProduct, + }, + Route{ + "InventoryUpdate", + "PUT", + "/products/id/{productID}/inventory", + UpdateInventory, + }, + Route{ + "CategoryIndex", + "GET", + "/categories/all", + CategoryIndex, + }, + Route{ + "CategoryShow", + "GET", + "/categories/id/{categoryID}", CategoryShow, }, } diff --git a/src/users/src/users-service/handlers.go b/src/users/src/users-service/handlers.go index de4d4c162..9f5a6d07d 100644 --- a/src/users/src/users-service/handlers.go +++ b/src/users/src/users-service/handlers.go @@ -35,9 +35,11 @@ func UserIndex(w http.ResponseWriter, r *http.Request) { panic(err) } - if i > -1 { - offset = i + if i < 0 { + http.Error(w, "Offset must be >= 0", http.StatusUnprocessableEntity) + return } + offset = i } var countParam = keys.Get("count") @@ -47,25 +49,35 @@ func UserIndex(w http.ResponseWriter, r *http.Request) { panic(err) } - if i > 0 { - count = i + if i < 1 { + http.Error(w, "Count must be > 0", http.StatusUnprocessableEntity) + return } + + if i > 10000 { + http.Error(w, "Count exceeds maximum value; please use paging by offset", http.StatusUnprocessableEntity) + return + } + + count = i } w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(http.StatusOK) var end = offset + count if end > len(users) { end = len(users) } - var ret []User + ret := make([]User, 0, count) - if offset < len(users) { - ret = users[offset:end] - } else { - ret = make([]User, 0) + idx := offset + for len(ret) < count && idx < len(users) { + // Do NOT return any users with an associated identity ID. + if len(users[idx].IdentityId) == 0 { + ret = append(ret, users[idx]) + } + idx++ } if err := json.NewEncoder(w).Encode(ret); err != nil { diff --git a/src/users/src/users-service/repository.go b/src/users/src/users-service/repository.go index da188231e..df049f8dc 100644 --- a/src/users/src/users-service/repository.go +++ b/src/users/src/users-service/repository.go @@ -105,8 +105,13 @@ func RepoUpdateUser(t User) User { u.SignUpDate = t.SignUpDate u.LastSignInDate = t.LastSignInDate + if len(u.IdentityId) > 0 && u.IdentityId != t.IdentityId { + delete(usersByIdentityId, u.IdentityId) + } + + u.IdentityId = t.IdentityId + if len(t.IdentityId) > 0 { - u.IdentityId = t.IdentityId usersByIdentityId[t.IdentityId] = idx } @@ -124,9 +129,24 @@ func RepoCreateUser(t User) (User, error) { } idx := len(users) - t.ID = strconv.Itoa(idx) + + if len(t.ID) > 0 { + // ID provided by caller (provisionally created on storefront) so make + // sure it's not already taken. + if _, ok := usersById[t.ID]; ok { + return User{}, errors.New("User with this ID already exists") + } + } else { + t.ID = strconv.Itoa(idx) + } + users = append(users, t) usersById[t.ID] = idx usersByUsername[t.Username] = idx + + if len(t.IdentityId) > 0 { + usersByIdentityId[t.IdentityId] = idx + } + return t, nil } diff --git a/src/web-ui/.env b/src/web-ui/.env index 1b4d04b35..15d4fa1f6 100644 --- a/src/web-ui/.env +++ b/src/web-ui/.env @@ -35,4 +35,6 @@ VUE_APP_BOT_REGION=us-west-2 # Configure Pinpoint VUE_APP_PINPOINT_APP_ID= -VUE_APP_PINPOINT_REGION=us-west-2 \ No newline at end of file +VUE_APP_PINPOINT_REGION=us-west-2 + +VUE_APP_SEGMENT_WRITE_KEY= \ No newline at end of file diff --git a/src/web-ui/src/analytics/AnalyticsHandler.js b/src/web-ui/src/analytics/AnalyticsHandler.js index 15cc60b67..a3dacd824 100644 --- a/src/web-ui/src/analytics/AnalyticsHandler.js +++ b/src/web-ui/src/analytics/AnalyticsHandler.js @@ -6,6 +6,7 @@ * (event tracker), and partner integrations. */ import Vue from 'vue'; +import AmplifyStore from '@/store/store'; import { Analytics as AmplifyAnalytics } from '@aws-amplify/analytics'; import Amplitude from 'amplitude-js' import { RepositoryFactory } from '@/repositories/RepositoryFactory' @@ -207,11 +208,12 @@ export const AnalyticsHandler = { AmplifyAnalytics.record({ eventType: 'ProductAdded', - userId: user ? user.id : null, + userId: user ? user.id : AmplifyStore.state.provisionalUserID, properties: { itemId: product.id } }, 'AmazonPersonalize') + AmplifyStore.commit('incrementSessionEventsRecorded'); let eventProperties = { userId: user ? user.id : null, @@ -305,11 +307,12 @@ export const AnalyticsHandler = { AmplifyAnalytics.record({ eventType: 'ProductQuantityUpdated', - userId: user ? user.id : null, + userId: user ? user.id : AmplifyStore.state.provisionalUserID, properties: { itemId: cartItem.product_id } }, 'AmazonPersonalize') + AmplifyStore.commit('incrementSessionEventsRecorded'); let eventProperties = { cartId: cart.id, @@ -349,11 +352,12 @@ export const AnalyticsHandler = { AmplifyAnalytics.record({ eventType: 'ProductViewed', - userId: user ? user.id : null, + userId: user ? user.id : AmplifyStore.state.provisionalUserID, properties: { itemId: product.id } }, 'AmazonPersonalize'); + AmplifyStore.commit('incrementSessionEventsRecorded'); if (experimentCorrelationId) { RecommendationsRepository.recordExperimentOutcome(experimentCorrelationId) @@ -406,11 +410,12 @@ export const AnalyticsHandler = { for (var item in cart.items) { AmplifyAnalytics.record({ eventType: 'CartViewed', - userId: user ? user.id : null, + userId: user ? user.id : AmplifyStore.state.provisionalUserID, properties: { itemId: cart.items[item].product_id } }, 'AmazonPersonalize') + AmplifyStore.commit('incrementSessionEventsRecorded'); } let eventProperties = { @@ -449,11 +454,12 @@ export const AnalyticsHandler = { for (var item in cart.items) { AmplifyAnalytics.record({ eventType: 'CheckoutStarted', - userId: user ? user.id : null, + userId: user ? user.id : AmplifyStore.state.provisionalUserID, properties: { itemId: cart.items[item].product_id } }, 'AmazonPersonalize') + AmplifyStore.commit('incrementSessionEventsRecorded'); } let eventProperties = { @@ -509,11 +515,12 @@ export const AnalyticsHandler = { AmplifyAnalytics.record({ eventType: 'OrderCompleted', - userId: user ? user.id : null, + userId: user ? user.id : AmplifyStore.state.provisionalUserID, properties: { itemId: orderItem.product_id } }, 'AmazonPersonalize') + AmplifyStore.commit('incrementSessionEventsRecorded'); if (this.amplitudeEnabled()) { // Amplitude revenue diff --git a/src/web-ui/src/authenticated/Profile.vue b/src/web-ui/src/authenticated/Profile.vue index 5a23bd256..b20cd149c 100644 --- a/src/web-ui/src/authenticated/Profile.vue +++ b/src/web-ui/src/authenticated/Profile.vue @@ -8,7 +8,7 @@

{{ user.username }}

-
{{ user.persona }}
+
{{ user.persona }}

@@ -20,7 +20,16 @@
@@ -99,6 +108,7 @@ import { RepositoryFactory } from '@/repositories/RepositoryFactory' import { AnalyticsHandler } from '@/analytics/AnalyticsHandler' import { AmplifyEventBus } from 'aws-amplify-vue'; +import { Credentials } from '@aws-amplify/core'; import AmplifyStore from '@/store/store' @@ -117,6 +127,8 @@ export default { return { errors: [], user: null, + authdUser: null, + identityId: null, saving: false, users: [], newUserId: AmplifyStore.state.user.id @@ -124,6 +136,7 @@ export default { }, created () { this.getUser(AmplifyStore.state.user.id) + this.getAuthdUser() this.getUsers() }, methods: { @@ -134,11 +147,18 @@ export default { } return this.user }, + async getAuthdUser() { + const credentials = await Credentials.get(); + if (credentials && credentials.identityId) { + this.identityId = credentials.identityId; + const { data } = await UsersRepository.getUserByIdentityId(credentials.identityId); + this.authdUser = data + } + }, async getUsers() { // More users than we can display in dropdown so limit to 300. - const start = Math.max(0, parseInt(AmplifyStore.state.user.id) - 100) - const { data } = await UsersRepository.get(start, start + 300) - this.users = data + const { data } = await UsersRepository.get(0, 300); + this.users = this.users.concat(data); }, async saveChanges () { this.saving = true; diff --git a/src/web-ui/src/public/CategoryDetail.vue b/src/web-ui/src/public/CategoryDetail.vue index 78c012850..9be2568c2 100644 --- a/src/web-ui/src/public/CategoryDetail.vue +++ b/src/web-ui/src/public/CategoryDetail.vue @@ -82,8 +82,8 @@ export default { intermediate = data } - if (this.user && intermediate.length > 0) { - const response = await RecommendationsRepository.getRerankedItems(this.user.id, intermediate, ExperimentFeature) + if (this.personalizeUserID && intermediate.length > 0) { + const response = await RecommendationsRepository.getRerankedItems(this.personalizeUserID, intermediate, ExperimentFeature) if (response.headers) { if (response.headers['x-personalize-recipe']) { @@ -116,6 +116,9 @@ export default { computed: { user() { return AmplifyStore.state.user + }, + personalizeUserID() { + return AmplifyStore.getters.personalizeUserID } }, filters: { diff --git a/src/web-ui/src/public/Main.vue b/src/web-ui/src/public/Main.vue index e5d2f1072..83cf9fa75 100644 --- a/src/web-ui/src/public/Main.vue +++ b/src/web-ui/src/public/Main.vue @@ -1,8 +1,8 @@