diff --git a/.gitignore b/.gitignore index 7c5b4ba..9a84bb0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ _ignored/ .vscode/launch.json .vscode/extensions.json *.code-workspace - +*.gitconfig # VisualStudioCode Patch # Ignore all local history of files .history @@ -37,4 +37,6 @@ _ignored/ # MacOS .DS_Store -.idea \ No newline at end of file +.idea + +samconfig.yaml \ No newline at end of file diff --git a/docs/api2.yaml b/docs/api2.yaml index d1aa6c1..914af60 100644 --- a/docs/api2.yaml +++ b/docs/api2.yaml @@ -1143,6 +1143,9 @@ components: label: type: string example: Becoming vegetarian + points: + type: int + example: 30 description: type: string example: I will not eat any meat from any animal (including fish). @@ -1157,6 +1160,7 @@ components: id: no-beef label: Not eating beef description: I will avoid eating beef (Goodbye stake). + points: 30 Date: type: string pattern: '\d{4}-\d{2}-\d{2}' diff --git a/internal/crowdactions/crowdaction.go b/internal/crowdactions/crowdaction.go new file mode 100644 index 0000000..54c2445 --- /dev/null +++ b/internal/crowdactions/crowdaction.go @@ -0,0 +1,42 @@ +package crowdaction + +import ( + "context" + "fmt" + + m "github.com/CollActionteam/collaction_backend/internal/models" + "github.com/CollActionteam/collaction_backend/utils" +) + +type Service interface { + GetCrowdactionById(ctx context.Context, crowdactionId string) (*m.CrowdactionData, error) + GetCrowdactionsByStatus(ctx context.Context, status string, startFrom *utils.PrimaryKey) ([]m.CrowdactionData, error) +} +type CrowdactionManager interface { + GetById(pk string, crowdactionId string) (*m.CrowdactionData, error) + GetByStatus(filterCond string, startFrom *utils.PrimaryKey) ([]m.CrowdactionData, error) +} + +const ( + KeyDateStart = "date_start" + KeyDateEnd = "date_end" + KeyDateJoinBefore = "date_limit_join" +) + +type crowdactionService struct { + crowdactionRepository CrowdactionManager +} + +func NewCrowdactionService(crowdactionRepository CrowdactionManager) Service { + return &crowdactionService{crowdactionRepository: crowdactionRepository} +} + +func (e *crowdactionService) GetCrowdactionById(ctx context.Context, crowdactionID string) (*m.CrowdactionData, error) { + fmt.Println("GetCrowdactionById", crowdactionID) + return e.crowdactionRepository.GetById(utils.PKCrowdaction, crowdactionID) +} + +func (e *crowdactionService) GetCrowdactionsByStatus(ctx context.Context, status string, startFrom *utils.PrimaryKey) ([]m.CrowdactionData, error) { + fmt.Println("GetCrowdactionsByStatus", status, startFrom) + return e.crowdactionRepository.GetByStatus(status, startFrom) +} diff --git a/internal/crowdactions/crowdaction_test.go b/internal/crowdactions/crowdaction_test.go new file mode 100644 index 0000000..5ae810a --- /dev/null +++ b/internal/crowdactions/crowdaction_test.go @@ -0,0 +1,36 @@ +package crowdaction_test + +import ( + "context" + "fmt" + "testing" + + cwd "github.com/CollActionteam/collaction_backend/internal/crowdactions" + m "github.com/CollActionteam/collaction_backend/internal/models" + "github.com/CollActionteam/collaction_backend/pkg/mocks/repository" + + "github.com/CollActionteam/collaction_backend/utils" + "github.com/stretchr/testify/assert" +) + +func TestCrowdaction_GetCrowdactionById(t *testing.T) { + as := assert.New(t) + dynamoRepository := &repository.Dynamo{} + var ctx context.Context + var crowdactions *m.CrowdactionData + crowdactionID := "sustainability#food#185f66fd" + + t.Run("dev stage", func(t *testing.T) { + dynamoRepository.On("GetById", utils.PKCrowdaction, crowdactionID).Return(crowdactions, nil).Once() + + service := cwd.NewCrowdactionService(dynamoRepository) + + crowdaction, err := service.GetCrowdactionById(ctx, crowdactionID) + + fmt.Println("Hello world", crowdaction) + + as.NoError(err) + + dynamoRepository.AssertExpectations(t) + }) +} diff --git a/internal/models/commitment.go b/internal/models/commitment.go new file mode 100644 index 0000000..46eec0d --- /dev/null +++ b/internal/models/commitment.go @@ -0,0 +1,9 @@ +package models + +type CommitmentOption struct { + Id string `json:"id"` + Label string `json:"label"` + Description string `json:"description"` + Requires []CommitmentOption `json:"requires,omitempty"` + Points int `json:"points"` +} diff --git a/internal/models/crowdaction.go b/internal/models/crowdaction.go new file mode 100644 index 0000000..67ec0c9 --- /dev/null +++ b/internal/models/crowdaction.go @@ -0,0 +1,32 @@ +package models + +type CrowdactionRequest struct { + Data CrowdactionData `json:"data" validate:"required"` +} + +type CrowdactionParticipant struct { + Name string `json:"name,omitempty"` + UserID string `json:"userID,omitempty"` +} + +type CrowdactionImages struct { + Card string `json:"card,omitempty"` + Banner string `json:"banner,omitempty"` +} + +type CrowdactionData struct { + CrowdactionID string `json:"crowdactionID"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + Location string `json:"location"` + DateStart string `json:"date_start"` + DateEnd string `json:"date_end"` + DateLimitJoin string `json:"date_limit_join"` + PasswordJoin string `json:"password_join"` + ParticipationCount int `json:"participant_count"` + TopParticipants []CrowdactionParticipant `json:"top_participants"` + Images CrowdactionImages `json:"images"` + CommitmentOptions []CommitmentOption `json:"commitment_options"` +} diff --git a/pkg/handler/aws/crowdaction/main.go b/pkg/handler/aws/crowdaction/main.go new file mode 100644 index 0000000..dc32435 --- /dev/null +++ b/pkg/handler/aws/crowdaction/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + + cwd "github.com/CollActionteam/collaction_backend/internal/crowdactions" + "github.com/CollActionteam/collaction_backend/internal/models" + hnd "github.com/CollActionteam/collaction_backend/pkg/handler" + awsRepository "github.com/CollActionteam/collaction_backend/pkg/repository/aws" + "github.com/CollActionteam/collaction_backend/utils" + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/go-playground/validator/v10" +) + +func getCrowdactionByID(ctx context.Context, crowdactionID string) (events.APIGatewayV2HTTPResponse, error) { + dynamoRepository := awsRepository.NewCrowdaction(awsRepository.NewDynamo()) + getCrowdaction, err := cwd.NewCrowdactionService(dynamoRepository).GetCrowdactionById(ctx, crowdactionID) + + if err != nil { + return utils.CreateMessageHttpResponse(http.StatusInternalServerError, err.Error()), nil + } + if getCrowdaction == nil { + return utils.CreateMessageHttpResponse(http.StatusNotFound, "not participating"), nil + } + + jsonPayload, _ := json.Marshal(getCrowdaction) + return events.APIGatewayV2HTTPResponse{ + Body: string(jsonPayload), + StatusCode: http.StatusOK, + }, nil +} + +func getCrowdactionsByStatus(ctx context.Context, status string) (events.APIGatewayV2HTTPResponse, error) { + dynamoRepository := awsRepository.NewCrowdaction(awsRepository.NewDynamo()) + getCrowdactions, err := cwd.NewCrowdactionService(dynamoRepository).GetCrowdactionsByStatus(ctx, status, nil) + + if err != nil { + return utils.CreateMessageHttpResponse(http.StatusInternalServerError, err.Error()), nil + } + jsonPayload, _ := json.Marshal(getCrowdactions) + + return events.APIGatewayV2HTTPResponse{ + Body: string(jsonPayload), + StatusCode: http.StatusOK, + }, nil +} + +func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + crowdactionID := req.PathParameters["crowdactionID"] + var request models.CrowdactionRequest + + validate := validator.New() + if err := validate.StructCtx(ctx, request); err != nil { + body, _ := json.Marshal(hnd.Response{Status: hnd.StatusFail, Data: map[string]interface{}{"error": utils.ValidationResponse(err, validate)}}) + return events.APIGatewayV2HTTPResponse{Body: string(body), StatusCode: http.StatusBadRequest}, nil + } + + if crowdactionID == "" { + status := req.QueryStringParameters["status"] + return getCrowdactionsByStatus(ctx, status) + } + + return getCrowdactionByID(ctx, crowdactionID) +} + +func main() { + lambda.Start(handler) +} + +func errToResponse(err error, code int) events.APIGatewayV2HTTPResponse { + msg, _ := json.Marshal(map[string]string{"message": err.Error()}) + return events.APIGatewayV2HTTPResponse{Body: string(msg), StatusCode: code} +} diff --git a/pkg/handler/aws/emailContact/main.go b/pkg/handler/aws/emailContact/main.go index ac7129e..87ec045 100644 --- a/pkg/handler/aws/emailContact/main.go +++ b/pkg/handler/aws/emailContact/main.go @@ -3,6 +3,8 @@ package main import ( "context" "encoding/json" + "net/http" + "github.com/CollActionteam/collaction_backend/internal/contact" "github.com/CollActionteam/collaction_backend/internal/models" hnd "github.com/CollActionteam/collaction_backend/pkg/handler" @@ -12,7 +14,6 @@ import ( "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go/aws/session" "github.com/go-playground/validator/v10" - "net/http" ) func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { diff --git a/pkg/mocks/repository/dynamoManager.go b/pkg/mocks/repository/dynamoManager.go new file mode 100644 index 0000000..3d37bab --- /dev/null +++ b/pkg/mocks/repository/dynamoManager.go @@ -0,0 +1,21 @@ +package repository + +import ( + m "github.com/CollActionteam/collaction_backend/internal/models" + "github.com/CollActionteam/collaction_backend/utils" + "github.com/stretchr/testify/mock" +) + +type Dynamo struct { + mock.Mock +} + +func (d *Dynamo) GetById(pk string, sk string) (*m.CrowdactionData, error) { + args := d.Mock.Called(pk, sk) + return args.Get(0).(*m.CrowdactionData), args.Error(1) +} + +func (d *Dynamo) GetByStatus(filterCond string, startFrom *utils.PrimaryKey) ([]m.CrowdactionData, error) { + args := d.Mock.Called(filterCond, startFrom) + return args.Get(0).([]m.CrowdactionData), args.Error(1) +} diff --git a/pkg/repository/aws/crowdactionManager.go b/pkg/repository/aws/crowdactionManager.go new file mode 100644 index 0000000..fb29b7b --- /dev/null +++ b/pkg/repository/aws/crowdactionManager.go @@ -0,0 +1,89 @@ +package aws + +import ( + "fmt" + + "github.com/CollActionteam/collaction_backend/internal/constants" + m "github.com/CollActionteam/collaction_backend/internal/models" + "github.com/CollActionteam/collaction_backend/utils" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" + "github.com/aws/aws-sdk-go/service/dynamodb/expression" +) + +type Crowdaction interface { + GetById(pk string, sk string) (*m.CrowdactionData, error) + GetByStatus(status string, startFrom *utils.PrimaryKey) ([]m.CrowdactionData, error) +} + +const ( + KeyDateStart = "date_start" + KeyDateEnd = "date_end" + KeyDateJoinBefore = "date_limit_join" +) + +type crowdaction struct { + dbClient *Dynamo +} + +func NewCrowdaction(dynamo *Dynamo) Crowdaction { + return &crowdaction{dbClient: dynamo} +} + +/** + GET Crowdaction by Id +**/ +func (s *crowdaction) GetById(pk string, sk string) (*m.CrowdactionData, error) { + item, err := s.dbClient.GetDBItem(constants.TableName, pk, sk) + + if item == nil || err != nil { + return nil, err + } + + var c m.CrowdactionData + err = dynamodbattribute.UnmarshalMap(item, &c) + + return &c, err +} + +/** + GET Crowdaction by Status +**/ +func (s *crowdaction) GetByStatus(status string, startFrom *utils.PrimaryKey) ([]m.CrowdactionData, error) { + crowdactions := []m.CrowdactionData{} + var filterCond expression.ConditionBuilder + + switch status { + case "joinable": + filterCond = expression.Name(KeyDateJoinBefore).GreaterThan(expression.Value(utils.GetDateStringNow())) + fmt.Println("GetByStatus: joinable", filterCond) + case "active": + filterCond = expression.Name(KeyDateStart).LessThanEqual(expression.Value(utils.GetDateStringNow())) + fmt.Println("GetByStatus: active", filterCond) + case "ended": + filterCond = expression.Name(KeyDateEnd).LessThanEqual(expression.Value(utils.GetDateStringNow())) + fmt.Println("GetByStatus: ended", filterCond) + default: + fmt.Println("None of the edge cases matched") + } + + items, err := s.dbClient.Query(constants.TableName, filterCond, startFrom) + + if items == nil || err != nil { + return nil, err + } + + for _, foo := range items { + var crowdaction m.CrowdactionData + err := dynamodbattribute.UnmarshalMap(foo, &crowdaction) + + if err == nil { + crowdactions = append(crowdactions, crowdaction) + } + } + + if len(items) != len(crowdactions) { + err = fmt.Errorf("error unmarshelling %d items", len(items)-len(crowdactions)) + } + + return crowdactions, err +} diff --git a/pkg/repository/aws/dynamo.go b/pkg/repository/aws/dynamo.go index d915d43..0698ba8 100644 --- a/pkg/repository/aws/dynamo.go +++ b/pkg/repository/aws/dynamo.go @@ -4,11 +4,13 @@ import ( "fmt" "github.com/CollActionteam/collaction_backend/internal/constants" + "github.com/CollActionteam/collaction_backend/utils" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" "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" ) const ( @@ -90,6 +92,29 @@ func (s *Dynamo) GetDBItem(tableName string, pk string, sk string) (map[string]* return result.Item, nil } +func (s *Dynamo) Query(tableName string, filterCond expression.ConditionBuilder, startFrom *utils.PrimaryKey) ([]map[string]*dynamodb.AttributeValue, error) { + keyCond := expression.Key(utils.PartitionKey).Equal(expression.Value(utils.PKCrowdaction)) + expr, _ := expression.NewBuilder().WithKeyCondition(keyCond).WithFilter(filterCond).Build() + + var exclusiveStartKey utils.PrimaryKey + + if startFrom != nil { + exclusiveStartKey = *startFrom + } + + result, err := s.dbClient.Query(&dynamodb.QueryInput{ + Limit: aws.Int64(utils.CrowdactionsPageLength), + ExclusiveStartKey: exclusiveStartKey, + TableName: aws.String(tableName), + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + KeyConditionExpression: expr.KeyCondition(), + FilterExpression: expr.Filter(), + }) + + return result.Items, err +} + func (s *Dynamo) PutDBItem(tableName string, pk string, sk string, record interface{}) error { av, err := dynamodbattribute.MarshalMap(record) if err != nil { diff --git a/template.yaml b/template.yaml index 81eae6f..6f27144 100644 --- a/template.yaml +++ b/template.yaml @@ -1,9 +1,8 @@ -AWSTemplateFormatVersion: '2010-09-09' +AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: > CollAction backend # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst - Globals: Function: Timeout: 10 @@ -38,13 +37,12 @@ Parameters: Type: String NoEcho: true Description: "ARN of certificate for CloudFront in region us-east-1 (Only for custom domain)" - + Conditions: shouldUseCustomDomainNames: !Not [!Equals [!Ref DomainParameter, ""]] Resources: - - DnsRecords: + DnsRecords: Type: AWS::Route53::RecordSetGroup Condition: shouldUseCustomDomainNames Properties: @@ -85,14 +83,14 @@ Resources: StaticContentDistribution: Type: AWS::CloudFront::Distribution Condition: shouldUseCustomDomainNames - Properties: + Properties: DistributionConfig: DefaultCacheBehavior: AllowedMethods: [HEAD, DELETE, POST, GET, OPTIONS, PUT, PATCH] TargetOriginId: StaticBucketOrigin ViewerProtocolPolicy: redirect-to-https ForwardedValues: - QueryString: 'false' + QueryString: "false" Cookies: Forward: none Enabled: true @@ -102,7 +100,7 @@ Resources: - Id: StaticBucketOrigin DomainName: !Sub ${StaticHostingBucket}.s3.${ AWS::Region }.amazonaws.com S3OriginConfig: - OriginAccessIdentity: !Sub 'origin-access-identity/cloudfront/${CloudFrontOriginIdentity}' + OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginIdentity}" ViewerCertificate: AcmCertificateArn: !Ref AcmCertificateArnParameter MinimumProtocolVersion: TLSv1.1_2016 @@ -111,21 +109,21 @@ Resources: HttpApiDomainName: Type: AWS::ApiGatewayV2::DomainName Condition: shouldUseCustomDomainNames - Properties: + Properties: DomainName: !Sub "api${SubdomainSuffixParameter}.${DomainParameter}" - DomainNameConfigurations: + DomainNameConfigurations: - EndpointType: REGIONAL CertificateArn: !Ref Certificate HttpApiMapping: Type: AWS::ApiGatewayV2::ApiMapping Condition: shouldUseCustomDomainNames - Properties: + Properties: ApiMappingKey: "" DomainName: !Sub "api${SubdomainSuffixParameter}.${DomainParameter}" ApiId: !Ref HttpApi Stage: !Ref HttpApi.Stage - DependsOn: + DependsOn: - HttpApiDomainName - HttpApi @@ -152,7 +150,7 @@ Resources: CorsConfiguration: AllowMethods: [GET] AllowOrigins: [http://localhost:8080] - + EmailContactFunction: Type: AWS::Serverless::Function Properties: @@ -169,19 +167,19 @@ Resources: Auth: Authorizer: "NONE" Policies: - - Version: '2012-10-17' + - Version: "2012-10-17" Statement: - Effect: Allow Action: - - 'ses:SendEmail' - - 'ses:SendRawEmail' - - 'ssm:GetParameter' - Resource: '*' + - "ses:SendEmail" + - "ses:SendRawEmail" + - "ssm:GetParameter" + Resource: "*" ProfilePictureUploadBucket: - Type: 'AWS::S3::Bucket' + Type: "AWS::S3::Bucket" StaticHostingBucket: - Type: 'AWS::S3::Bucket' + Type: "AWS::S3::Bucket" Properties: AccessControl: Private WebsiteConfiguration: @@ -205,10 +203,10 @@ Resources: BUCKET: !Ref ProfilePictureUploadBucket Policies: - Statement: - - Effect: Allow - Action: - - s3:PutObject* - Resource: '*' + - Effect: Allow + Action: + - s3:PutObject* + Resource: "*" Events: ProfilePictureUpload: Type: HttpApi @@ -218,7 +216,7 @@ Resources: ApiId: !Ref HttpApi ProcessProfilePictureFunction: - Type: 'AWS::Serverless::Function' + Type: "AWS::Serverless::Function" Properties: CodeUri: process-profile-picture/ Handler: process-profile-picture @@ -228,17 +226,18 @@ Resources: # Beware of recursive execution! Double check referenced buckets! OUTPUT_BUCKET_NAME: !Ref StaticHostingBucket KEY_PREIFX: profile-pictures/ - CLOUDFRONT_DISTRIBUTION: !If [shouldUseCustomDomainNames, !Ref StaticContentDistribution, ""] + CLOUDFRONT_DISTRIBUTION: + !If [shouldUseCustomDomainNames, !Ref StaticContentDistribution, ""] Policies: - Statement: - - Effect: Allow - Action: - - s3:GetObject* - - s3:PutObject* - - s3:DeleteObject* - - rekognition:DetectModerationLabels - - cloudfront:CreateInvalidation - Resource: '*' + - Effect: Allow + Action: + - s3:GetObject* + - s3:PutObject* + - s3:DeleteObject* + - rekognition:DetectModerationLabels + - cloudfront:CreateInvalidation + Resource: "*" Events: S3Event: Type: S3 @@ -265,10 +264,10 @@ Resources: GlobalSecondaryIndexes: - IndexName: "invertedIndex" KeySchema: - - AttributeName: "sk" - KeyType: "HASH" - - AttributeName: "pk" - KeyType: "RANGE" + - AttributeName: "sk" + KeyType: "HASH" + - AttributeName: "pk" + KeyType: "RANGE" Projection: ProjectionType: "ALL" # Data duplication is less costly than additional per primary key lookups (?) ProvisionedThroughput: @@ -278,7 +277,7 @@ Resources: CrowdactionFunction: Type: AWS::Serverless::Function Properties: - CodeUri: crowdaction/ + CodeUri: pkg/handler/aws/crowdaction Handler: crowdaction Runtime: go1.x Events: @@ -303,8 +302,7 @@ Resources: TABLE_NAME: !Ref SingleTable Policies: - DynamoDBCrudPolicy: - TableName: - !Ref SingleTable + TableName: !Ref SingleTable ParticipationQueue: Type: AWS::SQS::Queue @@ -327,8 +325,7 @@ Resources: MaximumBatchingWindowInSeconds: 300 #5min Policies: - DynamoDBCrudPolicy: - TableName: - !Ref SingleTable + TableName: !Ref SingleTable ParticipationFunction: Type: AWS::Serverless::Function @@ -350,15 +347,14 @@ Resources: ApiId: !Ref HttpApi Policies: - DynamoDBCrudPolicy: - TableName: - !Ref SingleTable + TableName: !Ref SingleTable - Statement: - - Sid: ParticipationQueuePutRecordPolicy - Effect: Allow - Action: - - sqs:SendMessage - Resource: !GetAtt ParticipationQueue.Arn - + - Sid: ParticipationQueuePutRecordPolicy + Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt ParticipationQueue.Arn + ProfileCRUDFunction: Type: AWS::Serverless::Function Properties: @@ -377,8 +373,7 @@ Resources: PROFILE_TABLE: !Ref ProfileTable Policies: - DynamoDBCrudPolicy: - TableName: - !Ref ProfileTable + TableName: !Ref ProfileTable # TODO use table SingleTabel instead ProfileTable: @@ -401,5 +396,5 @@ Outputs: Description: "CloudFront distribution endpoint URL for static files" Value: !Sub "https://static${SubdomainSuffixParameter}.${DomainParameter}/" TableName: - Value: !Ref SingleTable - Description: Table name of the newly created DynamoDB table \ No newline at end of file + Value: !Ref SingleTable + Description: Table name of the newly created DynamoDB table