diff --git a/.eslintrc.json b/.eslintrc.json index e4f3b35..4e5cc13 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -10,6 +10,7 @@ "extends": ["prettier"], "plugins": ["@typescript-eslint", "prettier"], "rules": { - "prettier/prettier": ["error"] + "prettier/prettier": ["error"], + "no-return-await": "error" } } diff --git a/Dockerfile b/Dockerfile index fea29c4..74c199a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Uses the node base image with the latest LTS version -FROM node:14.16.0 +FROM node:14.17.0 # Informs Docker that the container listens on the # specified network ports at runtime EXPOSE 4000 diff --git a/Dockerfile-dev b/Dockerfile-dev new file mode 100644 index 0000000..74c199a --- /dev/null +++ b/Dockerfile-dev @@ -0,0 +1,12 @@ +# Uses the node base image with the latest LTS version +FROM node:14.17.0 +# Informs Docker that the container listens on the +# specified network ports at runtime +EXPOSE 4000 +COPY . app/ +# Changes working directory to the new directory just created +WORKDIR /app +# Installs npm dependencies on container +RUN npm ci +# Command container will actually run when called +CMD ["npx", "nodemon", "-w", "src", "--ext", "ts", "--exec", "ts-node", "src/index.ts"] diff --git a/README.md b/README.md index 0639c60..0368ad9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Then head over to [The playground GUI](https://localhost:4000) in the browser ## Run tests Scripts will create the insert sql files for a randomized fake data postgres database. This database is based of the actual db in photowall project. + ``` npm test ``` @@ -27,6 +28,6 @@ The tests are held in testcontainers [testcontainers-node](https://github.com/te Each test is build around inserting fake data in the empty db, fetching that data through the GraphQL and with raw Sql queries and then comparing the results. -To write new inserts into the DB generate sql files with js in the src/__test__/db_generator tool. Then load the new sql file in the containerSetup.ts file. +To write new inserts into the DB generate sql files with js in the src/**test**/db_generator tool. Then load the new sql file in the containerSetup.ts file. When you have the data you need in the db, write tests in all.test.ts file following the pattern already set. diff --git a/cloudformation/Makefile b/cloudformation/Makefile new file mode 100644 index 0000000..4f957e5 --- /dev/null +++ b/cloudformation/Makefile @@ -0,0 +1,120 @@ +## +# Example: +# make create-stack STACK_ENVIRONMENT_NAME=staging +# make create-stack STACK_ENVIRONMENT_NAME=production +# +# # make update-stack STACK_ENVIRONMENT_NAME=staging +## +STACK_NAME=graphql + +GITHUB_REPO := graphql-api-js +GITHUB_USER := Photowall +$(eval GITHUB_TOKEN=$(shell aws ssm get-parameter --with-decryption --name /github/CODE_PIPELINE_TOKEN --output text --query Parameter.Value)) + +AWS_REGION=$(shell aws configure get region) +AWS_ACCOUNT_ID=$(shell aws sts get-caller-identity --query Account --output text) + +# HOSTED_ZONE=photowall.com default in the template +# Cert ARN for the hosted zone +CERTIFICATE_ARN='arn:aws:acm:eu-west-1:954747537408:certificate/1278df32-006d-48a4-abd6-2bfccabb11fd' +DB_SECURITY_GROUP_ID_PRODUCTION=sg-0b5bafc1e0a97e848 +DB_SECURITY_GROUP_ID_STAGING=sg-001b3667edf19e1ce + + +checkdef = $(if $(value $(1)),,$(error $(1) not set)) + +DOCKER_IMAGE=$(AWS_ACCOUNT_ID).dkr.ecr.$(AWS_REGION).amazonaws.com/$(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) + +ifeq ($(STACK_ENVIRONMENT_NAME),production) + SUBDOMAIN=$(STACK_NAME) + GITHUB_BRANCH=master + DB_SECURITY_GROUP_ID=$(DB_SECURITY_GROUP_ID_PRODUCTION) + CPU_SIZE=1024 + MEMORY_SIZE=2GB +else + SUBDOMAIN=$(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) + GITHUB_BRANCH=$(STACK_ENVIRONMENT_NAME) + DB_SECURITY_GROUP_ID=$(DB_SECURITY_GROUP_ID_STAGING) + CPU_SIZE=512 + MEMORY_SIZE=1GB +endif + +check-create-initial-ecr-image: + @echo Checking for inital image $(DOCKER_IMAGE) + @aws ecr list-images --repository-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) 2>/dev/null | grep imageTag | grep -q latest; \ + status=$$?; \ + if [ $$status != 0 ]; \ + then \ + echo ""; \ + echo "ECR Repo for initial image does not exist, creating"; \ + echo ""; \ + aws ecr create-repository --repository $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME); \ + aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin $(AWS_ACCOUNT_ID).dkr.ecr.$(AWS_REGION).amazonaws.com; \ + docker pull nginx; \ + docker tag nginx $(AWS_ACCOUNT_ID).dkr.ecr.$(AWS_REGION).amazonaws.com/$(STACK_NAME)-$(STACK_ENVIRONMENT_NAME):latest; \ + docker push $(AWS_ACCOUNT_ID).dkr.ecr.$(AWS_REGION).amazonaws.com/$(STACK_NAME)-$(STACK_ENVIRONMENT_NAME):latest; \ + echo ""; \ + fi + +set-repository-lifecycle-policy: + @echo Setting lifecycle policy for repo $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) + @aws ecr put-lifecycle-policy \ + --repository-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) \ + --lifecycle-policy-text "file://ecr-lifecycle-policy.json" + +check-create-variables-set: + $(call checkdef,STACK_NAME) + $(call checkdef,STACK_ENVIRONMENT_NAME) + $(call checkdef,GITHUB_TOKEN) + $(call checkdef,GITHUB_USER) + $(call checkdef,GITHUB_REPO) + +check-update-variables-set: + $(call checkdef,STACK_ENVIRONMENT_NAME) + +delete-stack: + aws cloudformation delete-stack --stack-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) + aws ecr delete-repository --force --repository-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) + @echo "" + @echo "################################################################" + @echo "# Do not forget to delete the articast bucket and loggroup:" + @aws s3 ls | grep $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) + @echo "# aws s3 rb --force s3://" + @echo "" + @echo "aws logs delete-log-group --log-group-name /aws/codebuild/$(STACK_NAME)-$(STACK_ENVIRONMENT_NAME)" + @echo "aws logs delete-log-group --log-group-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME)" + @echo "################################################################" + +create-stack: check-create-variables-set check-create-initial-ecr-image + aws cloudformation create-stack --stack-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) --template-body file://ecs-service.yaml \ + --capabilities CAPABILITY_NAMED_IAM \ + --tags Key=EnvironmentName,Value=$(STACK_ENVIRONMENT_NAME) \ + --parameters ParameterKey="EnvironmentName",ParameterValue="$(STACK_ENVIRONMENT_NAME)" \ + ParameterKey="Image",ParameterValue="$(DOCKER_IMAGE)" \ + ParameterKey="Subdomain",ParameterValue="$(SUBDOMAIN)" \ + ParameterKey="Certificate",ParameterValue="$(CERTIFICATE_ARN)" \ + ParameterKey="DatabaseSecurityGroup",ParameterValue="$(DB_SECURITY_GROUP_ID)" \ + ParameterKey="GitHubRepo",ParameterValue="$(GITHUB_REPO)" \ + ParameterKey="GitHubUser",ParameterValue="$(GITHUB_USER)" \ + ParameterKey="GitHubBranch",ParameterValue="$(GITHUB_BRANCH)" \ + ParameterKey="GitHubToken",ParameterValue="$(GITHUB_TOKEN)" \ + ParameterKey="CpuSize",ParameterValue="$(CPU_SIZE)" \ + ParameterKey="MemorySize",ParameterValue="$(MEMORY_SIZE)" + +update-stack: set-repository-lifecycle-policy + $(call checkdef,STACK_NAME) + $(call checkdef,ENVIRONMENT_NAME) + aws cloudformation update-stack --stack-name $(STACK_NAME)-$(STACK_ENVIRONMENT_NAME) --template-body file://ecs-service.yaml \ + --capabilities CAPABILITY_NAMED_IAM \ + --tags Key=EnvironmentName,Value=$(STACK_ENVIRONMENT_NAME) \ + --parameters ParameterKey="EnvironmentName",UsePreviousValue=true \ + ParameterKey="Image",UsePreviousValue=true \ + ParameterKey="Subdomain",UsePreviousValue=true \ + ParameterKey="Certificate",UsePreviousValue=true \ + ParameterKey="DatabaseSecurityGroup",,UsePreviousValue=true \ + ParameterKey="GitHubRepo",UsePreviousValue=true \ + ParameterKey="GitHubUser",UsePreviousValue=true \ + ParameterKey="GitHubBranch",UsePreviousValue=true \ + ParameterKey="GitHubToken",UsePreviousValue=true \ + ParameterKey="CpuSize",ParameterValue="$(CPU_SIZE)" \ + ParameterKey="MemorySize",ParameterValue="$(MEMORY_SIZE)" diff --git a/cloudformation/ecr-lifecycle-policy.json b/cloudformation/ecr-lifecycle-policy.json new file mode 100644 index 0000000..bbc1859 --- /dev/null +++ b/cloudformation/ecr-lifecycle-policy.json @@ -0,0 +1,13 @@ +{ + "rules": [ + { + "rulePriority": 1, + "description": "Only keep 8 images", + "selection": { + "tagStatus": "any", + "countType": "imageCountMoreThan", + "countNumber": 8 + }, + "action": { "type": "expire" } + }] +} diff --git a/cloudformation/ecs-service.yaml b/cloudformation/ecs-service.yaml new file mode 100644 index 0000000..3099f95 --- /dev/null +++ b/cloudformation/ecs-service.yaml @@ -0,0 +1,527 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: CloudFormation template for a full E2E ECS service with CI/CD on Fargate. +Parameters: + VPC: + Type: AWS::EC2::VPC::Id + Default: vpc-6f596904 + Description: The id of the VPC + SubnetA: + Type: AWS::EC2::Subnet::Id + Default: subnet-6e596905 + Description: The id of the first subnet + SubnetB: + Type: AWS::EC2::Subnet::Id + Default: subnet-6d596906 + Description: The id of the second subnet + + DatabaseSecurityGroup: + Type: String + Description: Security group id to access the database + + Image: + Type: String + # Docker image for this service. It must have a latest tag + # Example 260851616142.dkr.ecr.eu-west-1.amazonaws.com/nginx + EnvironmentName: + Type: String + Description: Name of env such as staging or production + + # CPU + # 256 (.25 vCPU) - Available memory values: 0.5GB, 1GB, 2GB + # 512 (.5 vCPU) - Available memory values: 1GB, 2GB, 3GB, 4GB + # 1024 (1 vCPU) - Available memory values: 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB + # 2048 (2 vCPU) - Available memory values: Between 4GB and 16GB in 1GB increments + # 4096 (4 vCPU) - Available memory values: Between 8GB and 30GB in 1GB increments + CpuSize: + Type: Number + + # Memory + # 0.5GB, 1GB, 2GB - Available cpu values: 256 (.25 vCPU) + # 1GB, 2GB, 3GB, 4GB - Available cpu values: 512 (.5 vCPU) + # 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB - Available cpu values: 1024 (1 vCPU) + # Between 4GB and 16GB in 1GB increments - Available cpu values: 2048 (2 vCPU) + # Between 8GB and 30GB in 1GB increments - Available cpu values: 4096 (4 vCPU) + MemorySize: + Type: String + + ContainerPort: + Type: Number + Default: 4000 + LoadBalancerPort: + Type: Number + Default: 443 + HealthCheckPath: + Type: String + Default: /.well-known/apollo/server-health + + Certificate: + Type: String + # Update with the certificate ARN from Certificate Manager, which must exist in the same region. + HostedZoneName: + Type: String + Default: photowall.com + Subdomain: + Type: String + + # for autoscaling + MinContainers: + Type: Number + Default: 1 + # for autoscaling + MaxContainers: + Type: Number + Default: 4 + # target CPU utilization (%) + AutoScalingTargetValue: + Type: Number + Default: 50 + + # Github info for code pipeline + GitHubRepo: + Type: String + GitHubBranch: + Type: String + GitHubToken: + Type: String + NoEcho: true + GitHubUser: + Type: String + +Resources: + Cluster: + Type: AWS::ECS::Cluster + Properties: + ClusterName: !Join ["", [!Ref "AWS::StackName", Cluster]] + + TaskDefinition: + Type: AWS::ECS::TaskDefinition + # Makes sure the log group is created before it is used. + DependsOn: LogGroup + Properties: + # Name of the task definition. Subsequent versions of the task definition are grouped together under this name. + Family: !Join ["", [!Ref "AWS::StackName", TaskDefinition]] + # awsvpc is required for Fargate + NetworkMode: awsvpc + RequiresCompatibilities: + - FARGATE + Cpu: !Ref CpuSize + Memory: !Ref MemorySize + # A role needed by ECS. + # "The ARN of the task execution role that containers in this task can assume. All containers in this task are granted the permissions that are specified in this role." + # "There is an optional task execution IAM role that you can specify with Fargate to allow your Fargate tasks to make API calls to Amazon ECR." + ExecutionRoleArn: !Ref ExecutionRole + # "The Amazon Resource Name (ARN) of an AWS Identity and Access Management (IAM) role that grants containers in the task permission to call AWS APIs on your behalf." + TaskRoleArn: !Ref TaskRole + ContainerDefinitions: + - Name: !Ref AWS::StackName + Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${AWS::StackName}:latest + PortMappings: + - ContainerPort: !Ref ContainerPort + # Define which parameters should be fetched + Secrets: + - Name: "DATABASE_URL" + ValueFrom: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/graphql-api/DATABASE_URL" + - Name: "COGNITO_POOL_ID" + ValueFrom: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/cognito/ADMIN_POOL_ID" + # Send logs to CloudWatch Logs + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-region: !Ref AWS::Region + awslogs-group: !Ref LogGroup + awslogs-stream-prefix: ecs + + # A role needed by ECS + ExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Join ["", [!Ref "AWS::StackName", ExecutionRole]] + AssumeRolePolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: "sts:AssumeRole" + ManagedPolicyArns: + - "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" + - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess + # A role for the containers + TaskRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Join ["", [!Ref "AWS::StackName", TaskRole]] + AssumeRolePolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: "sts:AssumeRole" + + # A role needed for auto scaling + AutoScalingRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Join ["", [!Ref "AWS::StackName", AutoScalingRole]] + AssumeRolePolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: "sts:AssumeRole" + ManagedPolicyArns: + - "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceAutoscaleRole" + + ContainerSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: + !Join ["", [!Ref "AWS::StackName", ContainerSecurityGroup]] + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: !Ref ContainerPort + ToPort: !Ref ContainerPort + SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup + + LoadBalancerSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: + !Join ["", [!Ref "AWS::StackName", LoadBalancerSecurityGroup]] + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: !Ref LoadBalancerPort + ToPort: !Ref LoadBalancerPort + CidrIp: 0.0.0.0/0 + + Service: + Type: AWS::ECS::Service + # This dependency is needed so that the load balancer is setup correctly in time + DependsOn: + - ListenerHTTPS + Properties: + ServiceName: !Ref AWS::StackName + Cluster: !Ref Cluster + TaskDefinition: !Ref TaskDefinition + DeploymentConfiguration: + MinimumHealthyPercent: 100 + MaximumPercent: 200 + DesiredCount: 1 + # This may need to be adjusted if the container takes a while to start up + HealthCheckGracePeriodSeconds: 30 + LaunchType: FARGATE + NetworkConfiguration: + AwsvpcConfiguration: + # change to DISABLED if you're using private subnets that have access to a NAT gateway + AssignPublicIp: ENABLED + Subnets: + - !Ref SubnetA + - !Ref SubnetB + SecurityGroups: + - !Ref ContainerSecurityGroup + - !Ref DatabaseSecurityGroup + LoadBalancers: + - ContainerName: !Ref AWS::StackName + ContainerPort: !Ref ContainerPort + TargetGroupArn: !Ref TargetGroup + + TargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + HealthCheckIntervalSeconds: 15 + # will look for a 200 status code by default unless specified otherwise + HealthCheckPath: !Ref HealthCheckPath + HealthCheckTimeoutSeconds: 10 + UnhealthyThresholdCount: 2 + HealthyThresholdCount: 2 + Name: !Join ["", [!Ref "AWS::StackName", Group]] + Port: !Ref ContainerPort + Protocol: HTTP + TargetGroupAttributes: + - Key: deregistration_delay.timeout_seconds + Value: 60 # default is 300 + TargetType: ip + VpcId: !Ref VPC + + ListenerHTTPS: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + DefaultActions: + - TargetGroupArn: !Ref TargetGroup + Type: forward + LoadBalancerArn: !Ref LoadBalancer + Port: !Ref LoadBalancerPort + Protocol: HTTPS + Certificates: + - CertificateArn: !Ref Certificate + + LoadBalancer: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + Properties: + LoadBalancerAttributes: + - Key: idle_timeout.timeout_seconds + Value: 1200 + Name: !Join ["", [!Ref "AWS::StackName", LB]] + # "internal" is also an option + Scheme: internet-facing + SecurityGroups: + - !Ref LoadBalancerSecurityGroup + Subnets: + - !Ref SubnetA + - !Ref SubnetB + + DNSRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneName: !Join ["", [!Ref HostedZoneName, .]] + Name: !Join ["", [!Ref Subdomain, ., !Ref HostedZoneName, .]] + Type: A + AliasTarget: + DNSName: !GetAtt LoadBalancer.DNSName + HostedZoneId: !GetAtt LoadBalancer.CanonicalHostedZoneID + + LogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Join ["", [/ecs/, !Ref "AWS::StackName", TaskDefinition]] + + AutoScalingTarget: + Type: AWS::ApplicationAutoScaling::ScalableTarget + Properties: + MinCapacity: !Ref MinContainers + MaxCapacity: !Ref MaxContainers + ResourceId: !Join ["/", [service, !Ref Cluster, !GetAtt Service.Name]] + ScalableDimension: ecs:service:DesiredCount + ServiceNamespace: ecs + # "The Amazon Resource Name (ARN) of an AWS Identity and Access Management (IAM) role that allows Application Auto Scaling to modify your scalable target." + RoleARN: !GetAtt AutoScalingRole.Arn + AutoScalingPolicy: + Type: AWS::ApplicationAutoScaling::ScalingPolicy + Properties: + PolicyName: !Join ["", [!Ref "AWS::StackName", AutoScalingPolicy]] + PolicyType: TargetTrackingScaling + ScalingTargetId: !Ref AutoScalingTarget + TargetTrackingScalingPolicyConfiguration: + PredefinedMetricSpecification: + PredefinedMetricType: ECSServiceAverageCPUUtilization + ScaleInCooldown: 10 + ScaleOutCooldown: 10 + # Keep things at or lower than 50% CPU utilization, for example + TargetValue: !Ref AutoScalingTargetValue + + ## Deployment pipeline below + CodeBuildServiceRole: + Type: AWS::IAM::Role + Properties: + Path: / + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: codebuild.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: root + PolicyDocument: + Version: 2012-10-17 + Statement: + - Resource: "*" + Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + - ecr:GetAuthorizationToken + - Resource: !Sub arn:aws:s3:::${ArtifactBucket}/* + Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:GetObjectVersion + - Resource: !Sub arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${AWS::StackName} + Effect: Allow + Action: + - ecr:GetDownloadUrlForLayer + - ecr:BatchGetImage + - ecr:BatchCheckLayerAvailability + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - Resource: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/dockerhub/* + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + + CodePipelineServiceRole: + Type: AWS::IAM::Role + Properties: + Path: / + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: codepipeline.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: root + PolicyDocument: + Version: 2012-10-17 + Statement: + - Resource: + - !Sub arn:aws:s3:::${ArtifactBucket}/* + Effect: Allow + Action: + - s3:PutObject + - s3:GetObject + - s3:GetObjectVersion + - s3:GetBucketVersioning + - Resource: "*" + Effect: Allow + Action: + - ecs:List* + - ecs:Describe* + - ecs:RegisterTaskDefinition + - ecs:UpdateService + - codebuild:StartBuild + - codebuild:BatchGetBuilds + - iam:PassRole + + ArtifactBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Retain + + CodeBuildProject: + Type: AWS::CodeBuild::Project + Properties: + Artifacts: + Type: CODEPIPELINE + Source: + Type: CODEPIPELINE + BuildSpec: | + version: 0.2 + phases: + install: + runtime-versions: + docker: 19 + pre_build: + commands: + - echo Logging in to Docker Hub... + - echo $DOCKERHUB_PASSWORD | docker login --username $DOCKERHUB_USERNAME --password-stdin + - TAG="$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | head -c 8)" + - IMAGE_URI="${REPOSITORY_URI}:${TAG}" + - IMAGE_LATEST_URI="${REPOSITORY_URI}:latest" + build: + commands: + - docker build --tag "$IMAGE_URI" . + - docker tag "$IMAGE_URI" "$IMAGE_LATEST_URI" + post_build: + commands: + - echo Logging in to ECR to push image + - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com + - docker push "$IMAGE_URI" + - docker push "$IMAGE_LATEST_URI" + - printf '[{"name":"%s","imageUri":"%s"}]' "$IMAGE_NAME" "$IMAGE_LATEST_URI" > images.json + artifacts: + files: images.json + Environment: + ComputeType: BUILD_GENERAL1_SMALL + PrivilegedMode: true + Image: aws/codebuild/standard:4.0 + Type: LINUX_CONTAINER + EnvironmentVariables: + - Name: AWS_DEFAULT_REGION + Value: !Ref AWS::Region + - Name: AWS_ACCOUNT_ID + Value: !Ref AWS::AccountId + - Name: REPOSITORY_URI + Value: !Ref Image + - Name: IMAGE_NAME + Value: !Ref AWS::StackName + - Name: DOCKERHUB_USERNAME + Value: /dockerhub/username + Type: PARAMETER_STORE + - Name: DOCKERHUB_PASSWORD + Value: /dockerhub/password + Type: PARAMETER_STORE + Name: !Ref AWS::StackName + ServiceRole: !Ref CodeBuildServiceRole + + Pipeline: + Type: AWS::CodePipeline::Pipeline + Properties: + RoleArn: !GetAtt CodePipelineServiceRole.Arn + Name: !Join ["", [!Ref "AWS::StackName", Pipeline]] + ArtifactStore: + Type: S3 + Location: !Ref ArtifactBucket + Stages: + - Name: Source + Actions: + - Name: App + ActionTypeId: + Category: Source + Owner: ThirdParty + Version: 1 + Provider: GitHub + Configuration: + Owner: !Ref GitHubUser + Repo: !Ref GitHubRepo + Branch: !Ref GitHubBranch + OAuthToken: !Ref GitHubToken + OutputArtifacts: + - Name: App + RunOrder: 1 + - Name: Build + Actions: + - Name: Build + ActionTypeId: + Category: Build + Owner: AWS + Version: 1 + Provider: CodeBuild + Configuration: + ProjectName: !Ref CodeBuildProject + InputArtifacts: + - Name: App + OutputArtifacts: + - Name: BuildOutput + RunOrder: 1 + - Name: Deploy + Actions: + - Name: Deploy + ActionTypeId: + Category: Deploy + Owner: AWS + Version: 1 + Provider: ECS + Configuration: + ClusterName: !Ref Cluster + ServiceName: !Ref Service + FileName: images.json + InputArtifacts: + - Name: BuildOutput + RunOrder: 1 + +# What outputs to expose +Outputs: + Endpoint: + Description: Endpoint + Value: !Join ["", ["https://", !Ref DNSRecord]] + Service: + Value: Service + PipelineUrl: + Value: !Sub https://console.aws.amazon.com/codepipeline/home?region=${AWS::Region}#/view/${Pipeline} + ContainerSecurityGroup: + Value: !Ref ContainerSecurityGroup + TaskDefinition: + Value: !Ref TaskDefinition + TargetGroup: + Value: !Ref TargetGroup + LoadBalancerUrl: + Description: The URL of the ALB + Value: !GetAtt LoadBalancer.DNSName diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..7c8978c --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + api: + build: + context: ./ + dockerfile: ./Dockerfile-dev + volumes: + - ./:/app + container_name: photowall.graphql + hostname: graphql.local + ports: + - '4000:4000' + environment: + - COGNITO_POOL_ID=eu-west-1_3O4VfvPn7 + - DATABASE_URL=postgresql://fondberg:@docker.for.mac.localhost/photowall + +networks: + default: + external: + name: photowall-docker-network diff --git a/docker-compose.yml b/docker-compose.yml index e95a9f5..4d256fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,19 @@ -version: '3.7' +version: '3.8' services: api: build: context: ./ dockerfile: ./Dockerfile - volumes: - - ./:/app + # volumes: + # - ./:/app container_name: photowall.graphql hostname: graphql.local ports: - '4000:4000' environment: - COGNITO_POOL_ID=eu-west-1_3O4VfvPn7 + - DATABASE_URL=postgresql://fondberg:@docker.for.mac.localhost/photowall networks: default: diff --git a/package-lock.json b/package-lock.json index acd0ed8..05a34e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1251,9 +1251,9 @@ "dev": true }, "@nodelib/fs.walk": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz", - "integrity": "sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "requires": { "@nodelib/fs.scandir": "2.1.5", @@ -1380,9 +1380,9 @@ } }, "@types/archiver": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz", - "integrity": "sha512-qJ79qsmq7O/k9FYwsF6O1xVA1PeLV+9Bh3TYkVCu3VzMR6vN9JQkgEOh/rrQ0R+F4Ta+R3thHGewxQtFglwVfg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.1.1.tgz", + "integrity": "sha512-heuaCk0YH5m274NOLSi66H1zX6GtZoMsdE6TYFcpFFjBjg0FoU4i4/M/a/kNlgNg26Xk3g364mNOYe1JaiEPOQ==", "dev": true, "requires": { "@types/glob": "*" @@ -1468,9 +1468,9 @@ "integrity": "sha512-C7srjHiVG3Ey1nR6d511dtDkCEjxuN9W1HWAEjGq8kpcwmNM6JJkpC0xvabM7BXTG2wDq8Eu33iH9aQKa7IvLQ==" }, "@types/dockerode": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.2.3.tgz", - "integrity": "sha512-nZRhpSxm3PYianRBcRExcHxDvEzYHUPfGCnRL5Fe4/fSEZbtxrRNJ7okzCans3lXxj2t298EynFHGTnTC2f1Iw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.2.4.tgz", + "integrity": "sha512-CSpvSohBQib0KCyg99h6RSmLZ4V9wF9hx6vT2FatplBNQpRaUzJh/XGjesRPzvqOlpp/Ol7aLFEgiE02nj/m2g==", "dev": true, "requires": { "@types/node": "*" @@ -1506,9 +1506,9 @@ } }, "@types/glob": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz", - "integrity": "sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-w+LsMxKyYQm347Otw+IfBXOv9UWVjpHpCDdbBMt8Kz/xbvCYNjP+0qPh91Km3iKfSRLBB0P7fAMf0KHrPu+MyA==", "dev": true, "requires": { "@types/minimatch": "*", @@ -1559,9 +1559,9 @@ } }, "@types/jest": { - "version": "26.0.23", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", - "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", + "version": "26.0.24", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", + "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", "dev": true, "requires": { "jest-diff": "^26.0.0", @@ -1569,9 +1569,9 @@ } }, "@types/json-schema": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", - "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.8.tgz", + "integrity": "sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg==", "dev": true }, "@types/keygrip": { @@ -1613,9 +1613,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "@types/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true }, "@types/node": { @@ -1649,9 +1649,9 @@ } }, "@types/ssh2": { - "version": "0.5.46", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.46.tgz", - "integrity": "sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA==", + "version": "0.5.47", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.47.tgz", + "integrity": "sha512-ZhqJg8BRV7OsCi0KVqPr27lUMMmLEeHYw1VXUNGGDlQEDq9HTsKx+wYvi8E6oNC6gRZ7PV99ZMZmMr5vztcYYA==", "dev": true, "requires": { "@types/node": "*", @@ -1659,9 +1659,9 @@ } }, "@types/ssh2-streams": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.8.tgz", - "integrity": "sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.9.tgz", + "integrity": "sha512-I2J9jKqfmvXLR5GomDiCoHrEJ58hAOmFrekfFqmCFd+A6gaEStvWnPykoWUwld1PNg4G5ag1LwdA+Lz1doRJqg==", "dev": true, "requires": { "@types/node": "*" @@ -1682,9 +1682,9 @@ } }, "@types/yargs": { - "version": "15.0.13", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", - "integrity": "sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ==", + "version": "15.0.14", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", + "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1697,13 +1697,13 @@ "dev": true }, "@typescript-eslint/eslint-plugin": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.28.2.tgz", - "integrity": "sha512-PGqpLLzHSxq956rzNGasO3GsAPf2lY9lDUBXhS++SKonglUmJypaUtcKzRtUte8CV7nruwnDxtLUKpVxs0wQBw==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.28.3.tgz", + "integrity": "sha512-jW8sEFu1ZeaV8xzwsfi6Vgtty2jf7/lJmQmDkDruBjYAbx5DA8JtbcMnP0rNPUG+oH5GoQBTSp+9613BzuIpYg==", "dev": true, "requires": { - "@typescript-eslint/experimental-utils": "4.28.2", - "@typescript-eslint/scope-manager": "4.28.2", + "@typescript-eslint/experimental-utils": "4.28.3", + "@typescript-eslint/scope-manager": "4.28.3", "debug": "^4.3.1", "functional-red-black-tree": "^1.0.1", "regexpp": "^3.1.0", @@ -1738,28 +1738,28 @@ } }, "@typescript-eslint/experimental-utils": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.28.2.tgz", - "integrity": "sha512-MwHPsL6qo98RC55IoWWP8/opTykjTp4JzfPu1VfO2Z0MshNP0UZ1GEV5rYSSnZSUI8VD7iHvtIPVGW5Nfh7klQ==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.28.3.tgz", + "integrity": "sha512-zZYl9TnrxwEPi3FbyeX0ZnE8Hp7j3OCR+ELoUfbwGHGxWnHg9+OqSmkw2MoCVpZksPCZYpQzC559Ee9pJNHTQw==", "dev": true, "requires": { "@types/json-schema": "^7.0.7", - "@typescript-eslint/scope-manager": "4.28.2", - "@typescript-eslint/types": "4.28.2", - "@typescript-eslint/typescript-estree": "4.28.2", + "@typescript-eslint/scope-manager": "4.28.3", + "@typescript-eslint/types": "4.28.3", + "@typescript-eslint/typescript-estree": "4.28.3", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0" } }, "@typescript-eslint/parser": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.28.2.tgz", - "integrity": "sha512-Q0gSCN51eikAgFGY+gnd5p9bhhCUAl0ERMiDKrTzpSoMYRubdB8MJrTTR/BBii8z+iFwz8oihxd0RAdP4l8w8w==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.28.3.tgz", + "integrity": "sha512-ZyWEn34bJexn/JNYvLQab0Mo5e+qqQNhknxmc8azgNd4XqspVYR5oHq9O11fLwdZMRcj4by15ghSlIEq+H5ltQ==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "4.28.2", - "@typescript-eslint/types": "4.28.2", - "@typescript-eslint/typescript-estree": "4.28.2", + "@typescript-eslint/scope-manager": "4.28.3", + "@typescript-eslint/types": "4.28.3", + "@typescript-eslint/typescript-estree": "4.28.3", "debug": "^4.3.1" }, "dependencies": { @@ -1781,29 +1781,29 @@ } }, "@typescript-eslint/scope-manager": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.28.2.tgz", - "integrity": "sha512-MqbypNjIkJFEFuOwPWNDjq0nqXAKZvDNNs9yNseoGBB1wYfz1G0WHC2AVOy4XD7di3KCcW3+nhZyN6zruqmp2A==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.28.3.tgz", + "integrity": "sha512-/8lMisZ5NGIzGtJB+QizQ5eX4Xd8uxedFfMBXOKuJGP0oaBBVEMbJVddQKDXyyB0bPlmt8i6bHV89KbwOelJiQ==", "dev": true, "requires": { - "@typescript-eslint/types": "4.28.2", - "@typescript-eslint/visitor-keys": "4.28.2" + "@typescript-eslint/types": "4.28.3", + "@typescript-eslint/visitor-keys": "4.28.3" } }, "@typescript-eslint/types": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.28.2.tgz", - "integrity": "sha512-Gr15fuQVd93uD9zzxbApz3wf7ua3yk4ZujABZlZhaxxKY8ojo448u7XTm/+ETpy0V0dlMtj6t4VdDvdc0JmUhA==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.28.3.tgz", + "integrity": "sha512-kQFaEsQBQVtA9VGVyciyTbIg7S3WoKHNuOp/UF5RG40900KtGqfoiETWD/v0lzRXc+euVE9NXmfer9dLkUJrkA==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.2.tgz", - "integrity": "sha512-86lLstLvK6QjNZjMoYUBMMsULFw0hPHJlk1fzhAVoNjDBuPVxiwvGuPQq3fsBMCxuDJwmX87tM/AXoadhHRljg==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.3.tgz", + "integrity": "sha512-YAb1JED41kJsqCQt1NcnX5ZdTA93vKFCMP4lQYG6CFxd0VzDJcKttRlMrlG+1qiWAw8+zowmHU1H0OzjWJzR2w==", "dev": true, "requires": { - "@typescript-eslint/types": "4.28.2", - "@typescript-eslint/visitor-keys": "4.28.2", + "@typescript-eslint/types": "4.28.3", + "@typescript-eslint/visitor-keys": "4.28.3", "debug": "^4.3.1", "globby": "^11.0.3", "is-glob": "^4.0.1", @@ -1838,12 +1838,12 @@ } }, "@typescript-eslint/visitor-keys": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.28.2.tgz", - "integrity": "sha512-aT2B4PLyyRDUVUafXzpZFoc0C9t0za4BJAKP5sgWIhG+jHECQZUEjuQSCIwZdiJJ4w4cgu5r3Kh20SOdtEBl0w==", + "version": "4.28.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.28.3.tgz", + "integrity": "sha512-ri1OzcLnk1HH4gORmr1dllxDzzrN6goUIz/P4MHFV0YZJDCADPR3RvYNp0PW2SetKTThar6wlbFTL00hV2Q+fg==", "dev": true, "requires": { - "@typescript-eslint/types": "4.28.2", + "@typescript-eslint/types": "4.28.3", "eslint-visitor-keys": "^2.0.0" } }, @@ -2051,15 +2051,48 @@ } }, "apollo-datasource-rest": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/apollo-datasource-rest/-/apollo-datasource-rest-0.14.0.tgz", - "integrity": "sha512-OXh+kbpZPx0F5fNJLYmxIa8Nt+UIC7oK1pnoWtLMDws9/TiICaG6J7SHHAKnz03ZVnZ4XE9gKtm9RTCjCYJtSw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/apollo-datasource-rest/-/apollo-datasource-rest-3.0.0.tgz", + "integrity": "sha512-pjx2uOvQSGHCE6fEeItTUniqYshL3OvD02ybwhbISg+7iQXKsyvgYKDgzY77tDjIiNSdUZsFOkNw2lmfnORBxA==", "requires": { - "apollo-datasource": "^0.9.0", - "apollo-server-caching": "^0.7.0", - "apollo-server-env": "^3.1.0", - "apollo-server-errors": "^2.5.0", - "http-cache-semantics": "^4.0.0" + "apollo-datasource": "^3.0.0", + "apollo-server-caching": "^3.0.0", + "apollo-server-env": "^4.0.0", + "apollo-server-errors": "^3.0.0", + "http-cache-semantics": "^4.1.0" + }, + "dependencies": { + "apollo-datasource": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-3.0.0.tgz", + "integrity": "sha512-mX+o0AQj/C4Z5M6r4g0BLRS6Ey7zxp4EvAhhLuMFB3xUnpcLxPxaq+FBLmxxo8OfE7Ay3AUe6mJgnAYIC2eqlg==", + "requires": { + "apollo-server-caching": "^3.0.0", + "apollo-server-env": "^4.0.0" + } + }, + "apollo-server-caching": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-3.0.0.tgz", + "integrity": "sha512-5ziYq8qv4PBAteY4dxVgiUq4h9OyQPY9BS/xadsyoa3VNChfXu1ByVrSx6v9hUcYkZAf5eioH0eT74EG/rjSgw==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "apollo-server-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-4.0.0.tgz", + "integrity": "sha512-NpdtkJRZq07XrZGjjrL0joU8N1OivUNYun00gafCxzZqNg+D0YkXIU0any6brXFh+u0qIXuvYS+eKFcjiVWCJg==", + "requires": { + "node-fetch": "^2.6.1", + "util.promisify": "^1.0.1" + } + }, + "apollo-server-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-3.0.0.tgz", + "integrity": "sha512-fyvyn/3EN4RO4A9GL26EWOEGbqmbeOy5vgh0MK9YErfKctWWNFvH4cuCWAy30hiqYnwR1apNKDedOKEhe+/CIg==" + } } }, "apollo-graphql": { @@ -4247,9 +4280,9 @@ "dev": true }, "fast-glob": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.6.tgz", - "integrity": "sha512-GnLuqj/pvQ7pX8/L4J84nijv6sAnlwvSDpMkJi9i7nPmPxGtRPkBSStfvDW5l6nMdX9VWe+pkKWFTgD+vF2QSQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", + "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", @@ -7786,9 +7819,9 @@ "dev": true }, "nodemon": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.9.tgz", - "integrity": "sha512-6O4k7C8f2HQArGpaPBOqGGddjzDLQAqCYmq3tKMeNIbz7Is/hOphMHy2dcY10sSq5wl3cqyn9Iz+Ep2j51JOLg==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.12.tgz", + "integrity": "sha512-egCTmNZdObdBxUBw6ZNwvZ/xzk24CKRs5K6d+5zbmrMr7rOpPmfPeF6OxM3DDpaRx331CQRFEktn+wrFFfBSOA==", "requires": { "chokidar": "^3.2.2", "debug": "^3.2.6", @@ -9318,9 +9351,9 @@ } }, "testcontainers": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-7.11.1.tgz", - "integrity": "sha512-lfZeys5bLkADjOaoXfQy0V0+G8sGKr8ESANz7MhSVBwC+OTTxkP3+FVwP48bW4mwRcQ4Hojwbfw10OYT80QZmQ==", + "version": "7.12.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-7.12.2.tgz", + "integrity": "sha512-rp35IGJPPB6ldOjho/LP2OTSmxI5RIpmd0G1HMgm7oYgdAmpRvSLazIHkUMzG3g7tg5fJtTl7qO18KUv+bU3YQ==", "dev": true, "requires": { "@types/archiver": "^5.1.0", @@ -9504,9 +9537,9 @@ } }, "ts-node": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.0.0.tgz", - "integrity": "sha512-ROWeOIUvfFbPZkoDis0L/55Fk+6gFQNZwwKPLinacRl6tsxstTF1DbAcLKkovwnpKMVvOMHP1TIbnwXwtLg1gg==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.1.0.tgz", + "integrity": "sha512-6szn3+J9WyG2hE+5W8e0ruZrzyk1uFLYye6IGMBadnOzDh8aP7t8CbFpsfCiEx2+wMixAhjFt7lOZC4+l+WbEA==", "requires": { "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", diff --git a/package.json b/package.json index 51e6cea..80fc4e8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "license": "ISC", "dependencies": { "apollo-datasource": "^0.9.0", - "apollo-datasource-rest": "^0.14.0", + "apollo-datasource-rest": "^3.0.0", "apollo-server": "^2.25.2", "async-redis": "^2.0.0", "axios": "^0.21.1", @@ -23,26 +23,26 @@ "jsonwebtoken": "^8.5.1", "jwk-to-pem": "^2.0.5", "knex-stringcase": "^1.4.5", - "nodemon": "^2.0.9", + "nodemon": "^2.0.12", "pg": "^8.6.0", "pg-hstore": "^2.3.4", "prettier": "^2.3.2", "stringcase": "^4.3.1", - "ts-node": "^10.0.0", + "ts-node": "^10.1.0", "typescript": "^4.3.5", "util": "^0.12.4", "validator": "^13.6.0" }, "devDependencies": { - "@types/jest": "^26.0.23", - "@typescript-eslint/eslint-plugin": "^4.28.2", - "@typescript-eslint/parser": "^4.28.2", + "@types/jest": "^26.0.24", + "@typescript-eslint/eslint-plugin": "^4.28.3", + "@typescript-eslint/parser": "^4.28.3", "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", "isomorphic-fetch": "^3.0.0", "jest": "^27.0.6", - "testcontainers": "^7.11.1", + "testcontainers": "^7.12.2", "ts-jest": "^27.0.3" } } diff --git a/queries.graphql b/queries.graphql index 7656eb7..2f8bb29 100644 --- a/queries.graphql +++ b/queries.graphql @@ -1,32 +1,29 @@ { products(limit: 10000, browsable: true, visible: true) { id + keywords { + id + value + type + } + type + fields_json inserted + path updated visible browsable - printProducts { - group + categories { id - updated + name + path + pathNumeric + isLeaf + depth + childCount + lft + rgt } - blacklisting { - group - id - marketId - updated - } - } -} - -{ - product(id: 42280) { - id - inserted - updated - visible - browsable - designerId designer { id name @@ -46,37 +43,78 @@ } } +# Orders { orders( - filter: { limit: 1, dates: { from: "2021-03-01", to: "2021-04-30" } } - ) { - id - market { - id - name - vat - currency - priceAdjustment + filter: { + limit: 100 + offset: 0 + dates: { from: "2021-03-01", to: "2021-04-30" } } - rows { + ) { + total + offset + limit + items { id - order { + paid + delivered + confirmed + billingInformation { + email + phone + address { + firstname + lastname + recipientName + companyname + address1 + address2 + zipcode + stateCountyOrRegion + } + } + deliveryInformation { + email + phone + address { + firstname + lastname + recipientName + companyname + address1 + address2 + zipcode + stateCountyOrRegion + } + } + market { id + name + vat + currency + priceAdjustment + } + rows { + id + order { + id + } + inserted + orderId + productId + productGroup + artNo + path + name + price + designerId + commissionAmount + status + pwintyImageId + frameColor + pwintySku } - inserted - orderId - productId - productGroup - artNo - path - name - price - designerId - commissionAmount - status - pwintyImageId - frameColor - pwintySku } } } @@ -154,14 +192,51 @@ # JULY 2021 { - product(id: 73803) { + product(id: 42166) { id + stockid + fields_json + orientation + related { + id + fields { + name + } + } + ref1 + ref2 + ref3 + wallpaperTypes keywords { id value type } - fields_json + type + fields { + artNo + name + height + width + photowallResolution + canvasResolution + wallpaperResolution + copyright + proportionsWarning + marginWidthMax + marginWidthMin + marginHeightMax + marginHeightMin + focusXpoint + focusYpoint + focusXpoint2 + focusYpoint2 + batch + imageResolution + printFileWidth + printFileHeight + printFileDpi + } inserted path updated @@ -171,6 +246,7 @@ id name path + pathNumeric isLeaf depth childCount @@ -186,6 +262,14 @@ group id updated + inserted + interiors { + id + roomName + uri + position + roomType + } } blacklisting { group @@ -195,7 +279,6 @@ } } } - # Categories { category(id: 1653) { @@ -236,3 +319,23 @@ } } } + +{ + markets { + id + name + vat + currency + priceAdjustment + } +} + +{ + market(name: "GB") { + id + name + vat + currency + priceAdjustment + } +} diff --git a/src/__test__/all.test.ts b/src/__test__/all.test.ts index 3b49d79..7d52512 100644 --- a/src/__test__/all.test.ts +++ b/src/__test__/all.test.ts @@ -140,7 +140,7 @@ describe('GraphQL Apollo tests', () => { it('should be possible to fetch a designers with limit', async () => { // CONTROL const db_response = await allContainers.db.raw( - `SELECT * FROM designers LIMIT 3;`, + `SELECT * FROM designers ORDER BY name LIMIT 3;`, ); const controlData = db_response.rows; // ------------------- @@ -186,10 +186,6 @@ describe('GraphQL Apollo tests', () => { expect(designers.length).toBe(3); - expect(designers[0].id).toBe('1'); - expect(designers[1].id).toBe('2'); - expect(designers[2].id).toBe('3'); - expect(designers[0].name).toBe(controlData[0].name); expect(designers[1].name).toBe(controlData[1].name); expect(designers[2].name).toBe(controlData[2].name); diff --git a/src/datasources/api1-datasource.ts b/src/datasources/api1-datasource.ts deleted file mode 100644 index 26b4e2e..0000000 --- a/src/datasources/api1-datasource.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { RESTDataSource } from 'apollo-datasource-rest'; - -export class Api1DataSource extends RESTDataSource { - authHeader: string; - constructor() { - super(); - this.baseURL = 'http://docker.for.mac.localhost:8082'; - this.authHeader = 'Basic ZGV2OmRldg=='; - } - - willSendRequest(request) { - request.headers.set('Authorization', this.authHeader); - } - - async getProduct(id) { - return await this.get(`/products/${id}?` + new URLSearchParams({}), null, { - cacheOptions: { ttl: 5 }, - }); - } -} diff --git a/src/datasources/api2-datasource.ts b/src/datasources/api2-datasource.ts deleted file mode 100644 index dc8d946..0000000 --- a/src/datasources/api2-datasource.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { RESTDataSource } from 'apollo-datasource-rest'; - -/* -https://wE4D45790.api.esales.apptus.cloud/api/v2/panels/category -?esales.market=SE& -esales.customerKey=471c09ac-a775-4dd2-8bfc-041bfbb6d7b7& -esales.sessionKey=b106299c-eac0-4f6f-96d2-93e57c255784& -window_first=1& -window_last=10& -selected_category=categories_SE:'root/animals'& -root_category=categories_SE:'root' -&max_facets=50 -&filter=blacklisted:'0' AND visible_in_listing:'1' AND visible_in_shop:'1' AND (group:'poster' OR group:'framed-print') -blacklisted:'0'%20AND%20visible_in_listing:'1'%20AND%20visible_in_shop:'1'%20AND%20(group:'poster'%20OR%20group:'framed-print') - -*/ -export class Api2DataSource extends RESTDataSource { - authHeader: string; - constructor() { - super(); - this.baseURL = 'https://api-staging.photowall.com/'; - this.authHeader = - 'Basic ZGExNzU5ZWEtOTBiNC00OTNjLWJjYWItNjAwNDg1NjA5YjkyOg=='; - } - - willSendRequest(request) { - request.headers.set('Authorization', this.authHeader); - } - - async getPrice(market, width, height, sku) { - return await this.get( - 'prices/wallpaper?' + - new URLSearchParams({ - width: '200', - height: '200', - market: '1', - product: '58941', - }), - ); - } -} diff --git a/src/datasources/category-api.ts b/src/datasources/category-api.ts index 87a48b5..055aea7 100644 --- a/src/datasources/category-api.ts +++ b/src/datasources/category-api.ts @@ -21,7 +21,7 @@ WHERE product_category.product_id = ? data.rows.map((row) => { return { ...row, - path: row.path.replace(/^root/, ''), + pathNumeric: row.path_numeric, }; }), ); @@ -29,33 +29,16 @@ WHERE product_category.product_id = ? } async getCategories(): Promise> { - return await this.knex - .select('*') - .from('v_categorytree') - .cache(MINUTE) - .then((rows) => { - return rows.map((row) => { - return { - ...row, - path: row.path.replace(/^root/, ''), - }; - }); - }); + return this.knex.select('*').from('v_categorytree').cache(MINUTE); } async getCategoryById(id: number): Promise { - return await this.knex + return this.knex .select('*') .from('v_categorytree') .where('id', id) .first() - .cache(MINUTE) - .then((row) => { - return { - ...row, - path: row.path.replace(/^root/, ''), - }; - }); + .cache(MINUTE); } /** @@ -65,10 +48,18 @@ WHERE product_category.product_id = ? SELECT category_keyword.category_id, keywords.value FROM category_keyword JOIN keywords ON category_keyword.keyword_id = keywords.id; - - FROM categorydata cd JOIN categorydatakeys cdk ON cd.datakey_id = cdk.id * + * category texts + SELECT name, + text + FROM categorydata cd + JOIN categorydatakeys cdk ON cd.datakey_id = cdk.id + WHERE category_id = ? + AND locale_id = ?; + + + * category teaser images??? */ } diff --git a/src/datasources/designer-api.ts b/src/datasources/designer-api.ts index 6d4a30e..fb37045 100644 --- a/src/datasources/designer-api.ts +++ b/src/datasources/designer-api.ts @@ -9,7 +9,7 @@ export class DesignerAPI extends BaseSQLDataSource { } async getDesignerById(id: number): Promise { - return await this.knex + return this.knex .select('*') .from('designers') .where('designerid', id) @@ -24,11 +24,12 @@ export class DesignerAPI extends BaseSQLDataSource { } async getDesigners(limit: Maybe): Promise> { - limit = limit ?? 100; - return await this.knex + limit = limit ?? 5000; + return this.knex .select('*') .from('designers') .limit(limit) + .orderBy('name') .cache(MINUTE) .then((rows) => { return rows.map((row) => { diff --git a/src/datasources/imageserver-api.ts b/src/datasources/imageserver-api.ts new file mode 100644 index 0000000..07ef01e --- /dev/null +++ b/src/datasources/imageserver-api.ts @@ -0,0 +1,29 @@ +import { RESTDataSource } from 'apollo-datasource-rest'; +import { InteriorType } from '../types/interior-types'; +import { Orientation } from '../types/product-types'; + +export class ImageServerApi extends RESTDataSource { + constructor() { + super(); + this.baseURL = + process.env.ENVIRONMENT_NAME === 'production' + ? 'http://images.photowall.com' + : 'http://images-dev.photowall.com'; + } + + willSendRequest(request) { + request.headers.set('X-Photowall', 'pele'); + } + + async getInteriorsForProduct( + productId: number, + type: InteriorType, + orientation: Orientation, + ): Promise> { + const it = type.toString().toLowerCase(); + const or = orientation.toString().toLowerCase(); + return this.get(`/rooms/${productId}/${or}/${it}/`, null, { + cacheOptions: { ttl: 5 }, + }); + } +} diff --git a/src/datasources/interior-api.ts b/src/datasources/interior-api.ts new file mode 100644 index 0000000..871c9e9 --- /dev/null +++ b/src/datasources/interior-api.ts @@ -0,0 +1,122 @@ +import { + Interior, + InteriorsFilter, + InteriorType, +} from '../types/interior-types'; +import { Orientation } from '../types/product-types'; +import { Maybe } from '../types/types'; +import { BaseSQLDataSource } from './BaseSQLDataSource'; +import { ImageServerApi } from './imageserver-api'; +import { convertToPossibleType } from './utils'; + +const MINUTE = 60; + +export class InteriorAPI extends BaseSQLDataSource { + imageServerApi: ImageServerApi; + constructor(config, imageServerApi: ImageServerApi) { + super(config); + this.imageServerApi = imageServerApi; + } + + getInteriorType(roomTypePart: string): string { + switch (roomTypePart) { + case 'wallpaper': + return InteriorType[InteriorType.WALLPAPER]; + case 'painting': + return InteriorType[InteriorType.PAINTING]; + case 'poster': + return InteriorType[InteriorType.POSTER]; + case 'framed-print': + return InteriorType[InteriorType.FRAMED_PRINT]; + default: + throw new Error(`Unknown room type: ${roomTypePart}`); + } + } + + getInteriorOrientation(roomOrientationPart: string): string { + switch (roomOrientationPart) { + case 'landscape': + return Orientation[Orientation.LANDSCAPE]; + case 'standing': + return Orientation[Orientation.PORTRAIT]; + case 'square': + return Orientation[Orientation.SQUARE]; + default: + throw new Error(`Unknown room orientation: ${roomOrientationPart}`); + } + } + + getInteriorRoomNumber(roomNumberPart: string): number { + return convertToPossibleType(roomNumberPart.slice(4)); + } + + decorateRow(row: any): Interior { + const parts = row.roomName.split('_'); + row.roomNumber = this.getInteriorRoomNumber(parts[0]); + row.type = this.getInteriorType(parts[1]); + row.orientation = this.getInteriorOrientation(parts[2]); + return row; + } + + async getInteriors(filter: Maybe): Promise> { + const sql = /* sql */ ` + SELECT rooms.*, room_types.name roomType FROM rooms + LEFT JOIN room_types ON rooms.room_type_id = room_types.id + `; + + const allInteriors = await this.cachedRaw(sql) + .cache(MINUTE * 60) + .then((data) => + data.rows.map((row) => { + const res = { + ...row, + roomName: row.name, + roomType: row.roomtype, + }; + this.decorateRow(res); + return res; + }), + ); + + // Based on filter get valid interiors and filter out any else from all interiors + if (filter) { + const validUris = await this.imageServerApi.getInteriorsForProduct( + filter.productId, + filter.type, + filter.orientation, + ); + return allInteriors.filter((i: Interior) => { + const it = i.type.toString().toLowerCase(); + const or = i.orientation.toString().toLocaleLowerCase(); + const uriMatch = `${or}/${it}/room${i.roomNumber}.`; + const foundUri = validUris.find(({ uri }) => uri.includes(uriMatch)); + if (foundUri) { + i.uri = foundUri.uri; + return true; + } + return false; + }); + } + // No filter so return all + return allInteriors; + } + + async getPrintProductInteriors(printId: number): Promise> { + return this.knex + .select('*') + .from('v_interior_image_urls') + .orderBy('position') + .where('print_id', printId) + .cache(MINUTE) + .then((rows) => { + return rows.map((row) => { + const res = { + ...row, + id: row.roomId, + }; + this.decorateRow(res); + return res; + }); + }); + } +} diff --git a/src/datasources/keyword-api.ts b/src/datasources/keyword-api.ts index 0942e6b..003c87f 100644 --- a/src/datasources/keyword-api.ts +++ b/src/datasources/keyword-api.ts @@ -28,10 +28,9 @@ WHERE product_keyword.product_id = ? ORDER BY keywords.value `; - const res = await this.knex + return this.knex .raw(query, productId) .then((data) => data.rows.map((row) => this.getType(row))); - return res; } async getKeywords(): Promise> { @@ -40,14 +39,13 @@ SELECT keywords.*, keyword_type.type_id FROM keywords LEFT JOIN keyword_type ON keyword_type.keyword_id = keywords.id ORDER BY keywords.value `; - const res = await this.knex + return this.knex .raw(query) .then((data) => data.rows.map((row) => this.getType(row))); - return res; } async getKeywordById(id: number): Promise { - const res = await this.knex + return this.knex .select('*') .from('keywords') .leftJoin('keyword_type', 'keyword_type.keyword_id', 'keywords.id') @@ -55,7 +53,6 @@ ORDER BY keywords.value .first() .cache(MINUTE) .then((row) => this.getType(row)); - return res; } /** diff --git a/src/datasources/market-api.ts b/src/datasources/market-api.ts new file mode 100644 index 0000000..55f56c9 --- /dev/null +++ b/src/datasources/market-api.ts @@ -0,0 +1,35 @@ +import { SQLDataSource } from 'datasource-sql'; +import { Market } from '../types/market-types'; + +const MINUTE = 60; + +export class MarketAPI extends SQLDataSource { + constructor(config) { + super(config); + } + + async getMarketById(id: number): Promise { + return this.knex + .select('*') + .from('markets') + .where('id', id) + .first() + .cache(MINUTE * 5); + } + + async getMarketByName(name: string): Promise { + return this.knex + .select('*') + .from('markets') + .where('name', name) + .first() + .cache(MINUTE * 5); + } + + async getMarkets(): Promise> { + return this.knex + .select('*') + .from('markets') + .cache(MINUTE * 5); + } +} diff --git a/src/datasources/order-api.ts b/src/datasources/order-api.ts index 94bd25b..e0b9da9 100644 --- a/src/datasources/order-api.ts +++ b/src/datasources/order-api.ts @@ -3,7 +3,6 @@ import { camelcase } from 'stringcase'; import { Address, ContactInformation, - Market, Order, OrderRow, } from '../types/order-types'; @@ -17,24 +16,6 @@ export class OrderAPI extends BaseSQLDataSource { super(config); } - async getMarketById(id: number): Promise { - return await this.knex - .select('*') - .from('markets') - .where('id', id) - .first() - .cache(MINUTE); - } - - async getMarketByName(name: string): Promise { - return await this.knex - .select('*') - .from('markets') - .where('name', name) - .first() - .cache(MINUTE); - } - async getAddress(addressId: number): Promise> { const row = await this.knex .select('*') @@ -80,20 +61,13 @@ export class OrderAPI extends BaseSQLDataSource { return row; } - async getOrdersTotal(input: Maybe): Promise { - let query = this.knex.table('orders'); - - if (input?.dates?.from) { - query = query.where('inserted', '>=', input.dates.from); - } - - if (input?.dates?.to) { - query = query.where('inserted', '<=', input.dates.to); - } - - const res = await query.clone().count(); - console.log(res); - return 57777; + async getOrdersTotal(input: Maybe): Promise { + const res = await this.getOrdersQuery(input) + .clone() + .count() + .first() + .cache(MINUTE * 60); + return convertToPossibleType(res['count']); // Optimize later to return Promise } async getOrders(input: Maybe): Promise> { @@ -103,7 +77,7 @@ export class OrderAPI extends BaseSQLDataSource { query = query.limit(input?.limit); } query = query.orderBy('inserted', 'ASC'); - return await query + return query .cache(MINUTE) .then((rows) => rows.map((row) => this.createOrderFromRow(row))); } @@ -122,7 +96,7 @@ export class OrderAPI extends BaseSQLDataSource { } async getOrderById(id: number): Promise { - return await this.knex + return this.knex .select('*') .from('orders') .where('id', id) @@ -200,7 +174,7 @@ export class OrderAPI extends BaseSQLDataSource { .raw('SELECT * FROM "order-rows" WHERE orderid = ?', orderId) .then((data) => data.rows); - return await this.getOrderRows(rows); + return this.getOrderRows(rows); } async getOrderRowsByDesignerId( @@ -228,6 +202,6 @@ export class OrderAPI extends BaseSQLDataSource { const rows = await this.knex .raw(query, [designerId, this.getSQLDate(from), this.getSQLDate(to)]) .then((data) => data.rows); - return await this.getOrderRows(rows); + return this.getOrderRows(rows); } } diff --git a/src/datasources/product-api.ts b/src/datasources/product-api.ts index 128a94f..76e4f70 100644 --- a/src/datasources/product-api.ts +++ b/src/datasources/product-api.ts @@ -1,4 +1,5 @@ -import { SQLDataSource } from 'datasource-sql'; +import { BaseSQLDataSource } from './BaseSQLDataSource'; +import { camelcase } from 'stringcase'; import * as sql from './sql'; import { Product, @@ -6,12 +7,16 @@ import { PrintProduct, ProductType, ProductBlacklist, + ProductWallpaperType, + ProductFields, + Orientation, } from '../types/product-types'; import { Maybe } from '../types/types'; +import { convertToPossibleType } from './utils'; const MINUTE = 60; -export class ProductAPI extends SQLDataSource { +export class ProductAPI extends BaseSQLDataSource { constructor(config) { super(config); } @@ -21,6 +26,25 @@ export class ProductAPI extends SQLDataSource { // SELECT materialid, material, (price / 100::float) as price FROM "product-materials" // } + getWallpaperTypes(row: any): Array { + return row.wallpapertypes?.map((t: number) => ProductWallpaperType[t]); + } + + getProductTypes(row: any): Array { + if (!row.types) { + return []; + } + return row.types.map((type: any) => { + if (type === 'typeillustration') { + return ProductType[ProductType.ILLUSTRATION]; + } + if (type === 'typephotography') { + return ProductType[ProductType.PHOTO]; + } + return ProductType[ProductType.UNKNOWN]; + }); + } + getBlacklisting(row: any): Array { if (!row.blacklisting) { return []; @@ -42,26 +66,36 @@ export class ProductAPI extends SQLDataSource { }); } - getProductTypes(row: any): Array { - if (!row.types) { - return []; + getProductFields(row: any): ProductFields { + const fields = Object.keys(row.fields).reduce((mem, key) => { + mem[camelcase(key)] = convertToPossibleType(row.fields[key]); + return mem; + }, {}); + + return fields; + } + + getOrientation(fields: ProductFields): string { + if (!fields.width || !fields.height) { + return Orientation[Orientation.UNKNOWN]; } - return row.types.map((type: any) => { - if (type === 'typeillustration') { - return ProductType[ProductType.ILLUSTRATION]; - } - if (type === 'typephotography') { - return ProductType[ProductType.PHOTO]; - } - return ProductType[ProductType.UNKNOWN]; - }); + if (fields.width > fields.height) { + return Orientation[Orientation.LANDSCAPE]; + } + if (fields.width < fields.height) { + return Orientation[Orientation.PORTRAIT]; + } + return Orientation[Orientation.SQUARE]; } createProductFromRow(row: any) { row.blacklisting = this.getBlacklisting(row); row.printProducts = this.getPrintProducts(row); row.type = this.getProductTypes(row); + row.wallpaperTypes = this.getWallpaperTypes(row); + row.fields = this.getProductFields(row); row.designerId = row.designerid; + row.orientation = this.getOrientation(row.fields); row.fields_json = row.fields; return row; } @@ -74,7 +108,7 @@ export class ProductAPI extends SQLDataSource { limit = limit ?? 100; const query = sql.products(limit, visible, browsable); - return await this.knex + return this.knex .raw(query) .then((data) => data.rows.map((row) => this.createProductFromRow(row))); } @@ -94,7 +128,7 @@ export class ProductAPI extends SQLDataSource { async getCategoryProducts(categoryId: number): Promise> { const query = sql.categoryProducts(categoryId); - return await this.knex + return this.knex .raw(query) .then((data) => data.rows.map((row) => this.createProductFromRow(row))); } @@ -102,8 +136,18 @@ export class ProductAPI extends SQLDataSource { async getKeywordProducts(keywordId: number): Promise> { const query = sql.keywordProducts(keywordId); - return await this.knex + return this.knex .raw(query) .then((data) => data.rows.map((row) => this.createProductFromRow(row))); } + + async getRelatedProducts(id: number): Promise> { + const ids = await this.cachedRaw( + `SELECT productid2 FROM "product-products_products" WHERE productid1 = ${id}`, + ) + .cache(MINUTE * 60) + .then((data) => data.rows.map((r) => r.productid2)); + + return ids.map(async (id) => this.getProduct(id)); + } } diff --git a/src/datasources/sql/products-sql.ts b/src/datasources/sql/products-sql.ts index 3abc451..6533e16 100644 --- a/src/datasources/sql/products-sql.ts +++ b/src/datasources/sql/products-sql.ts @@ -4,57 +4,75 @@ import { Maybe } from '../../types/types'; const baseQuery = /* sql */ ` -SELECT products.productid as id, +SELECT products.productid AS id, + stock.stockid, products.path, products.visible, products.browsable, products.designerid, products.inserted, products.updated, + products.ref1, + products.ref2, + products.ref3, ( - SELECT json_agg( - json_build_object( - 'printId', - "product-printproducts".printid, - 'productId', - "product-printproducts".productid, - 'groupId', - "product-printproducts".groupid, - 'inserted', - "product-printproducts".inserted, - 'updated', - "product-printproducts".updated + SELECT json_agg( + json_build_object( + 'printId', + "product-printproducts".printid, + 'productId', + "product-printproducts".productid, + 'groupId', + "product-printproducts".groupid, + 'inserted', + "product-printproducts".inserted, + 'updated', + "product-printproducts".updated + ) ) - ) FROM "product-printproducts" WHERE "product-printproducts".productid = products.productid + FROM "product-printproducts" + WHERE "product-printproducts".productid = products.productid ) AS printproducts, - ( SELECT json_agg( - json_build_object( - 'id', - product_blacklist.id, - 'productId', - product_blacklist.product_id, - 'marketId', - product_blacklist.market_id, - 'groupId', - product_blacklist.group_id, - 'inserted', - product_blacklist.created_at, - 'updated', - product_blacklist.updated_at + ( + SELECT json_agg( + json_build_object( + 'id', + product_blacklist.id, + 'productId', + product_blacklist.product_id, + 'marketId', + product_blacklist.market_id, + 'groupId', + product_blacklist.group_id, + 'inserted', + product_blacklist.created_at, + 'updated', + product_blacklist.updated_at + ) ) - ) FROM product_blacklist WHERE product_blacklist.product_id = products.productid + FROM product_blacklist + WHERE product_blacklist.product_id = products.productid ) AS blacklisting, - ( SELECT json_object_agg(fields.field, pf.value) + ( + SELECT json_object_agg(fields.field, pf.value) FROM "product-products_fields" pf - JOIN "product-fields" fields ON fields.fieldid = pf.fieldid + JOIN "product-fields" fields ON fields.fieldid = pf.fieldid WHERE pf.productid = products.productid ) AS fields, - ( SELECT json_agg(keywords.value) + ( + SELECT json_agg(keywords.value) FROM product_keyword pk - JOIN keywords ON keywords.id = pk.keyword_id - WHERE keywords.id IN (1103,1104)AND pk.product_id = products.productid - ) AS types - FROM "product-products" products + JOIN keywords ON keywords.id = pk.keyword_id + WHERE keywords.id IN (1103, 1104) + AND pk.product_id = products.productid + ) AS types, + ( + SELECT json_agg(wt.wallpapertype_id) + FROM product_wallpapertypes wt + WHERE wt.product_id = products.productid + ) AS wallpapertypes +FROM "product-products" products + LEFT JOIN "product-stockproducts" stock ON stock.productid = products.productid `; export function products( diff --git a/src/index.ts b/src/index.ts index fbc5a82..569bf71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,33 +9,34 @@ import resolvers from './resolvers'; import { OrderAPI } from './datasources/order-api'; import { ProductAPI } from './datasources/product-api'; import { DesignerAPI } from './datasources/designer-api'; -import { Api2DataSource } from './datasources/api2-datasource'; -import { Api1DataSource } from './datasources/api1-datasource'; - -import { verifyToken } from './cognito/cognito-client'; import { CategoryAPI } from './datasources/category-api'; import { KeywordAPI } from './datasources/keyword-api'; +import { MarketAPI } from './datasources/market-api'; +import { ImageServerApi } from './datasources/imageserver-api'; +import { InteriorAPI } from './datasources/interior-api'; -console.log(`dbConfig`, dbConfig); +import { verifyToken } from './cognito/cognito-client'; // Should we convert columns? const knexConfig = knexStringcase(dbConfig); // set up any dataSources our resolvers need -const api1 = new Api1DataSource(); -const api2 = new Api2DataSource(); +const categoryApi = new CategoryAPI(knexConfig); +const designerApi = new DesignerAPI(knexConfig); +const imageServerApi = new ImageServerApi(); +const interiorApi = new InteriorAPI(knexConfig, imageServerApi); +const keywordApi = new KeywordAPI(knexConfig); +const marketApi = new MarketAPI(knexConfig); const orderApi = new OrderAPI(knexConfig); const productApi = new ProductAPI(knexConfig); -const categoryApi = new CategoryAPI(knexConfig); -const keywordApi = new KeywordAPI(knexConfig); -const designerApi = new DesignerAPI(knexConfig); const dataSources = () => ({ - api1, - api2, categoryApi, designerApi, + interiorApi, + imageServerApi, keywordApi, + marketApi, orderApi, productApi, }); @@ -51,6 +52,7 @@ const context = async ({ req }) => { const res = await verifyToken({ token }); return { auth: res }; } else if (authHeader == 'Basic ZGV2OmRldg==') { + // dev:dev remove later return { auth: 'during development' }; } throw new AuthenticationError('Not authorized'); diff --git a/src/resolvers/designers-resolver.ts b/src/resolvers/designers-resolver.ts index 44a7458..590e94f 100644 --- a/src/resolvers/designers-resolver.ts +++ b/src/resolvers/designers-resolver.ts @@ -16,7 +16,7 @@ async function getDesigners(_, { limit }, { dataSources }) { return (dataSources.designerApi).getDesigners(limit); } -async function getDesigner(_, { id }, { dataSources }) { +async function getDesigner(parent, { id }, { dataSources }) { return (dataSources.designerApi).getDesignerById(id); } diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index f25ad6b..ecb2812 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -1,9 +1,16 @@ import { orderQueryTypeDefs, orderTypeDefs } from './orders-resolver'; -import { productQueryTypeDefs, productTypeDefs } from './products-resolver'; +import { + productQueryTypeDefs, + productTypeDefs, + productMutationTypeDefs, +} from './products-resolver'; import { designerQueryTypeDefs, designerTypeDefs } from './designers-resolver'; import { categoryQueryTypeDefs, categoryTypeDefs } from './categories-resolver'; -import { DateTimeResolver, DateResolver, JSONResolver } from 'graphql-scalars'; import { keywordsQueryTypeDefs, keywordTypeDefs } from './keywords-resolver'; +import { marketsQueryTypeDefs, marketTypeDefs } from './markets-resolver'; + +import { DateTimeResolver, DateResolver, JSONResolver } from 'graphql-scalars'; +import { interiorsQueryTypeDefs, interiorTypeDefs } from './interiors-resolver'; const resolvers = { Query: { ...orderQueryTypeDefs, @@ -11,12 +18,19 @@ const resolvers = { ...designerQueryTypeDefs, ...categoryQueryTypeDefs, ...keywordsQueryTypeDefs, + ...marketsQueryTypeDefs, + ...interiorsQueryTypeDefs, + }, + Mutation: { + ...productMutationTypeDefs, }, ...orderTypeDefs, ...productTypeDefs, ...designerTypeDefs, ...categoryTypeDefs, ...keywordTypeDefs, + ...marketTypeDefs, + ...interiorTypeDefs, DateTime: DateTimeResolver, Date: DateResolver, JSON: JSONResolver, diff --git a/src/resolvers/interiors-resolver.ts b/src/resolvers/interiors-resolver.ts new file mode 100644 index 0000000..d786030 --- /dev/null +++ b/src/resolvers/interiors-resolver.ts @@ -0,0 +1,15 @@ +import { IResolverObject } from 'graphql-tools'; +import { InteriorAPI } from '../datasources/interior-api'; +import { InteriorsFilterInput } from '../types/interior-types'; + +const Interior: IResolverObject = {}; + +async function getInteriors(_, args: InteriorsFilterInput, { dataSources }) { + return (dataSources.interiorApi).getInteriors(args.filter); +} + +export const interiorTypeDefs = { Interior }; + +export const interiorsQueryTypeDefs = { + interiors: getInteriors, +}; diff --git a/src/resolvers/markets-resolver.ts b/src/resolvers/markets-resolver.ts new file mode 100644 index 0000000..1ffbac1 --- /dev/null +++ b/src/resolvers/markets-resolver.ts @@ -0,0 +1,19 @@ +import { IResolverObject } from 'graphql-tools'; +import { MarketAPI } from '../datasources/market-api'; + +const Market: IResolverObject = {}; + +async function getMarkets(_, _args, { dataSources }) { + return (dataSources.marketApi).getMarkets(); +} + +async function getMarket(_, { name }, { dataSources }) { + return (dataSources.marketApi).getMarketByName(name); +} + +export const marketTypeDefs = { Market }; + +export const marketsQueryTypeDefs = { + markets: getMarkets, + market: getMarket, +}; diff --git a/src/resolvers/orders-resolver.ts b/src/resolvers/orders-resolver.ts index b8a5979..4eabec6 100644 --- a/src/resolvers/orders-resolver.ts +++ b/src/resolvers/orders-resolver.ts @@ -1,4 +1,5 @@ import { IResolverObject } from 'graphql-tools'; +import { MarketAPI } from '../datasources/market-api'; import { OrderAPI } from '../datasources/order-api'; import { FilterInput } from '../types/types'; @@ -14,7 +15,7 @@ const Order: IResolverObject = { return (dataSources.orderApi).getOrderRowsByOrderId(id); }, async market({ market }, _args, { dataSources }) { - return (dataSources.orderApi).getMarketByName(market); + return (dataSources.marketApi).getMarketByName(market); }, }; diff --git a/src/resolvers/products-resolver.ts b/src/resolvers/products-resolver.ts index f74d3e8..c9ea854 100644 --- a/src/resolvers/products-resolver.ts +++ b/src/resolvers/products-resolver.ts @@ -1,16 +1,16 @@ import { IResolverObject } from 'graphql-tools'; -import { Api2DataSource } from '../datasources/api2-datasource'; -import { OrderAPI } from '../datasources/order-api'; import { ProductAPI } from '../datasources/product-api'; import { Product } from '../types/product-types'; import { CategoryAPI } from '../datasources/category-api'; import { Category } from '../types/category-types'; import { KeywordAPI } from '../datasources/keyword-api'; import { Keyword } from '../types/keyword-types'; +import { MarketAPI } from '../datasources/market-api'; +import { InteriorAPI } from '../datasources/interior-api'; const ProductBlacklist: IResolverObject = { async market(parent, _args, { dataSources }) { - return (dataSources.orderApi).getMarketById(parent.marketId); + return (dataSources.marketApi).getMarketById(parent.marketId); }, }; @@ -20,24 +20,22 @@ const Product: IResolverObject = { return (dataSources.categoryApi).getProductCategories(id); }, async designer({ designerId }, args, { dataSources }) { + if (!designerId) { + return null; + } return dataSources.designerApi.getDesignerById(designerId); }, async keywords({ id }, _args, { dataSources }): Promise> { return (dataSources.keywordApi).getProductKeywords(id); }, - async api1json({ id }, _args, { dataSources }): Promise { - return dataSources.api1.getProduct(id); + async related({ id }, _args, { dataSources }) { + return (dataSources.productApi).getRelatedProducts(id); }, }; const PrintProduct: IResolverObject = { - async price({ id }, { market, width, height, sku }, { dataSources }) { - return (dataSources.api2).getPrice( - market, - width, - height, - sku, - ); + async interiors({ id }, _args, { dataSources }) { + return (dataSources.interiorApi).getPrintProductInteriors(id); }, }; @@ -67,3 +65,11 @@ export const productQueryTypeDefs = { products: getProducts, product: getProduct, }; + +// Mutations below + +export const productMutationTypeDefs = { + addKeyword(_, { productId, keywordId }, { dataSources }) { + return { result: 'OK' }; + }, +}; diff --git a/src/schema.ts b/src/schema.ts index f3b9883..ef4f9c1 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -19,6 +19,26 @@ const typeDefs = gql` keyword(id: Int!): Keyword designers(limit: Int): [Designer] designer(id: Int!): Designer + markets: [Market] + market(name: String!): Market + interiors(filter: InteriorsFilterInput): [Interior] + } + + type Mutation { + addKeyword(productId: Int!, keywordId: Int!): MutationResult + } + + type MutationResult { + result: String! + } + + """ + Only return valid interiors for product, type and orientation + """ + input InteriorsFilterInput { + productId: Int! + type: InteriorType! + orientation: Orientation! } input DateInput { @@ -134,8 +154,8 @@ const typeDefs = gql` designerPath: String discountType: String discountValue: Float - displayHeight: Int - displayWidth: Int + displayHeight: Float + displayWidth: Float edge: String frameColor: String framed: Int @@ -175,6 +195,7 @@ const typeDefs = gql` id: ID! name: String path: String + pathNumeric: String isLeaf: Int depth: Int childCount: Int @@ -185,6 +206,7 @@ const typeDefs = gql` type Product { id: ID! + stockid: Int path: String! visible: Boolean! browsable: Boolean! @@ -196,15 +218,53 @@ const typeDefs = gql` designerId: Int designer: Designer keywords: [Keyword] + ref1: String + ref2: String + ref3: String + related: [Product] + wallpaperTypes: [ProductWallpaperType] + fields: ProductFields + orientation: Orientation """ - Will be single value return in later release + Will be single values return in later release """ type: [ProductType] - # Below are for debug - api1json: JSON + # Below are for debug and will be removed soon fields_json: JSON } + type ProductFields { + artNo: String! + name: String! + height: Int + width: Int + photowallResolution: Int + canvasResolution: Int + wallpaperResolution: Int + copyright: String + proportionsWarning: String + marginWidthMax: Int + marginWidthMin: Int + marginHeightMax: Int + marginHeightMin: Int + focusXpoint: Float + focusYpoint: Float + focusXpoint2: Float + focusYpoint2: Float + batch: String + imageResolution: Int + printFileWidth: Int + printFileHeight: Int + printFileDpi: Int + } + + enum Orientation { + UNKNOWN + PORTRAIT + LANDSCAPE + SQUARE + } + enum ProductGroup { PHOTO_WALLPAPER CANVAS @@ -223,6 +283,11 @@ const typeDefs = gql` ILLUSTRATION } + enum ProductWallpaperType { + WALLMURAL + DESIGN + } + type ProductBlacklist { id: ID! groupId: Int! @@ -239,7 +304,28 @@ const typeDefs = gql` group: ProductGroup! inserted: DateTime! updated: DateTime - price(market: Int, width: Int, height: Int, sku: String): JSON + interiors: [Interior] + } + + enum InteriorType { + WALLPAPER + PAINTING + POSTER + FRAMED_PRINT + } + + type Interior { + """ + room id + """ + id: ID! + roomName: String + position: Int + roomType: String + uri: String + roomNumber: Int + type: InteriorType + orientation: Orientation } type Designer { @@ -255,7 +341,7 @@ const typeDefs = gql` } type Keyword { - id: Int! + id: ID! value: String! type: KeywordType products: [Product] @@ -321,7 +407,7 @@ orderrows example } - +types: "doityourselfframe" "fixed" "tiling" diff --git a/src/types/category-types.ts b/src/types/category-types.ts index 69f3653..fd52758 100644 --- a/src/types/category-types.ts +++ b/src/types/category-types.ts @@ -2,6 +2,7 @@ export interface Category { id: number; name: string; path: string; + pathNumeric: string; isLeaf: number; depth: number; childCount: number; diff --git a/src/types/interior-types.ts b/src/types/interior-types.ts new file mode 100644 index 0000000..382ad70 --- /dev/null +++ b/src/types/interior-types.ts @@ -0,0 +1,29 @@ +import { Orientation } from './product-types'; + +export interface Interior { + id: number; + roomName: string; + position: number; + roomType: string; + uri: string; + roomNumber: number; + type: InteriorType; + orientation: Orientation; +} + +export enum InteriorType { + WALLPAPER, + PAINTING, + POSTER, + FRAMED_PRINT, +} + +export interface InteriorsFilter { + productId: number; + type: InteriorType; + orientation: Orientation; +} + +export interface InteriorsFilterInput { + filter?: InteriorsFilter; +} diff --git a/src/types/market-types.ts b/src/types/market-types.ts new file mode 100644 index 0000000..f7b2665 --- /dev/null +++ b/src/types/market-types.ts @@ -0,0 +1,7 @@ +export interface Market { + id: number; + name: string; + vat: number; + currency: string; + priceAdjustment: number; +} diff --git a/src/types/order-types.ts b/src/types/order-types.ts index edd4780..15e100d 100644 --- a/src/types/order-types.ts +++ b/src/types/order-types.ts @@ -1,13 +1,3 @@ -import { DateInput } from './types'; - -export interface Market { - id: number; - name: string; - vat: number; - currency: string; - priceAdjustment: number; -} - export interface Address { id: number; firstname: string; diff --git a/src/types/product-types.ts b/src/types/product-types.ts index 4b7ac12..02d46c0 100644 --- a/src/types/product-types.ts +++ b/src/types/product-types.ts @@ -15,6 +15,19 @@ export enum ProductType { PHOTO = 1, ILLUSTRATION = 2, } + +export enum ProductWallpaperType { + WALLMURAL = 1, + DESIGN = 2, +} + +export enum Orientation { + UNKNOWN = 0, + PORTRAIT = 1, + LANDSCAPE = 2, + SQUARE = 3, +} + export interface ProductBlacklist { id: number; groupId: number; @@ -34,16 +47,44 @@ export interface PrintProduct { export interface Product { id: number; + stockid: number; path: string; visible: boolean; browsable: boolean; inserted: Date; updated?: Date; + orientation?: Orientation; printProducts: [PrintProduct]; blacklisting: [ProductBlacklist]; type: [ProductType]; - api1json: JSON; - fields_json: JSON; + fields: ProductFields; + fields_json: JSON; // remove later +} + +// TODO: test stock products to see what props can be mandatory +export interface ProductFields { + artNo: string; + name: string; + height?: number; + width?: number; + photowallResolution?: number; + canvasResolution?: number; + wallpaperResolution?: number; + copyright?: string; + proportionsWarning?: string; + marginWidthMax?: number; + marginWidthMin?: number; + marginHeightMax?: number; + marginHeightMin?: number; + focusXpoint?: number; + focusYpoint?: number; + focusXpoint2?: number; + focusYpoint2?: number; + batch?: string; + imageResolution?: number; + printFileWidth?: number; + printFileHeight?: number; + printFileDpi?: number; } export function getProductGroupStringFromString(group: string): string { diff --git a/stockproducts.txt b/stockproducts.txt new file mode 100644 index 0000000..853e425 --- /dev/null +++ b/stockproducts.txt @@ -0,0 +1,43 @@ + +When added as order rows : + + + +kit 47851: +'stockId' +'productId' 47851 +'type' // always 'stock' +'artNo' +'path' +'name' +'baseName' +'price' +'weight' + + +dyi frame 45479: +'stockId' +'productId' 45479 +'product_description' +'type' // always 'stock' +'artNo' +'path' +'name' +'price' +'width' +'height' +'weight' +'measure_unit' +'displayWidth' +'displayHeight' + + +sample: +'productId' +'name' +'artNo' +'material' +'weight' +'price' +'type' // always 'stock' +'path'