From e95342007111434281fd126832516d604c4d5fd3 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Wed, 22 Oct 2025 19:31:48 -0600 Subject: [PATCH 01/10] Add initial code to get Subscriptions --- app/go/go.mod | 24 +++++- app/go/go.sum | 34 ++++++++ app/go/src/dtos/dtos.go | 21 +++++ app/go/src/dtos/status.go | 10 +++ app/go/src/entities/subscription.go | 27 ++++++ app/go/src/handlers/subscription.go | 125 ++++++++++++++++++++++++++++ app/go/src/helpers.go | 35 ++++++++ app/go/src/main.go | 44 ++++++++-- 8 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 app/go/src/dtos/dtos.go create mode 100644 app/go/src/dtos/status.go create mode 100644 app/go/src/entities/subscription.go create mode 100644 app/go/src/handlers/subscription.go create mode 100644 app/go/src/helpers.go diff --git a/app/go/go.mod b/app/go/go.mod index 79410d3..37fa55a 100755 --- a/app/go/go.mod +++ b/app/go/go.mod @@ -2,4 +2,26 @@ module github.com/fenderdigital/fds-aws-coding-exercise go 1.24.5 -require github.com/aws/aws-lambda-go v1.49.0 +require ( + github.com/aws/aws-lambda-go v1.49.0 + github.com/aws/aws-sdk-go-v2 v1.39.3 + github.com/aws/aws-sdk-go-v2/config v1.31.14 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 // indirect + github.com/aws/smithy-go v1.23.1 // indirect +) diff --git a/app/go/go.sum b/app/go/go.sum index a5b506a..9831d5c 100755 --- a/app/go/go.sum +++ b/app/go/go.sum @@ -1,5 +1,39 @@ github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYLqIQ= github.com/aws/aws-lambda-go v1.49.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= +github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/config v1.31.14 h1:kj/KpDqvt0UqcEL3WOvCykE9QUpBb6b23hQdnXe+elo= +github.com/aws/aws-sdk-go-v2/config v1.31.14/go.mod h1:X5PaY6QCzViihn/ru7VxnIamcJQrG9NSeTxuSKm2YtU= +github.com/aws/aws-sdk-go-v2/credentials v1.18.18 h1:5AfxTvDN0AJoA7rg/yEc0sHhl6/B9fZ+NtiQuOjWGQM= +github.com/aws/aws-sdk-go-v2/credentials v1.18.18/go.mod h1:m9mE1mJ1s7zI6rrt7V3RQU2SCgUbNaphlfqEksLp+Fs= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 h1:IPznA4MUKCdHL28SZeuMhFxBSMTYZjm+lqLdQRKd6gM= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17/go.mod h1:OFWH4SmyLk8MzTWhk3XaveS3cNyY7SU6UQDPviMIEbM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 h1:mj/bdWleWEh81DtpdHKkw41IrS+r3uw1J/VQtbwYYp8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10/go.mod h1:7+oEMxAZWP8gZCyjcm9VicI0M61Sx4DJtcGfKYv2yKQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 h1:wh+/mn57yhUrFtLIxyFPh2RgxgQz/u+Yrf7hiHGHqKY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10/go.mod h1:7zirD+ryp5gitJJ2m1BBux56ai8RIRDykXZrJSp540w= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 h1:HWdbTAAa51HIg4jXyTtkHRU5ZF0n3+rNChldmveicDw= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1/go.mod h1:GyNGZUbiqJH5lMAVNlYlYXCNoJcCmyPAeLxlDKsmi1g= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 h1:AVmNRz6Sjfwug8mA314XbCOETbotDO1PtwZGk5bTy3I= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2/go.mod h1:IakOzjzwZN+7RAC1Hja1n0A466zBL9lx/I4KIDvJjUY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 h1:T0QsDQNCVealR4CrVt+spgWJgjl8oIDje/5TH8YnCmE= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10/go.mod h1:SGBJMtnGk4y9Yvrr3iNPos9WUqexJHxq2OI6Z1ch634= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 h1:xSL4IV19pKDASL2fjWXRfTGmZddPiPPZNPpbv6uZQZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.8/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/app/go/src/dtos/dtos.go b/app/go/src/dtos/dtos.go new file mode 100644 index 0000000..675cde9 --- /dev/null +++ b/app/go/src/dtos/dtos.go @@ -0,0 +1,21 @@ +package dtos + +type SubscriptionResponse struct { + UserID string `json:"userId"` + SubscriptionID string `json:"subscriptionId"` + Plan *SubscriptionResponsePlan `json:"plan"` + StartDate string `json:"startDate"` + ExpiresAt string `json:"expiresAt"` + CancelledAt *string `json:"cancelledAt"` + Status SubStatus `json:"status"` + Attributes map[string]interface{} `json:"attributes"` +} + +type SubscriptionResponsePlan struct { + SKU string `json:"sku"` + Name string `json:"name"` + Price float64 `json:"price"` + Currency string `json:"currency"` + BillingCycle string `json:"billingCycle"` + Features []string `json:"features"` +} diff --git a/app/go/src/dtos/status.go b/app/go/src/dtos/status.go new file mode 100644 index 0000000..e428bee --- /dev/null +++ b/app/go/src/dtos/status.go @@ -0,0 +1,10 @@ +package dtos + +type SubStatus string + +const ( + SubStatusActive SubStatus = "ACTIVE" + SubStatusInactive SubStatus = "INACTIVE" + SubStatusPending SubStatus = "PENDING" + SubStatusCancelled SubStatus = "CANCELLED" +) diff --git a/app/go/src/entities/subscription.go b/app/go/src/entities/subscription.go new file mode 100644 index 0000000..d720c13 --- /dev/null +++ b/app/go/src/entities/subscription.go @@ -0,0 +1,27 @@ +package entities + +type SubscriptionItem struct { + PK string `dynamodbav:"pk"` + SK string `dynamodbav:"sk"` + Type string `dynamodbav:"type"` + PlanSKU string `dynamodbav:"planSku"` + StartDate string `dynamodbav:"startDate"` + ExpiresAt string `dynamodbav:"expiresAt"` + CanceledAt *string `dynamodbav:"canceledAt"` + LastModifiedAt string `dynamodbav:"lastModifiedAt"` + Attributes map[string]interface{} `dynamodbav:"attributes"` +} + +type Plan struct { + PK string `dynamodbav:"pk"` + SK string `dynamodbav:"sk"` + Type string `dynamodbav:"type"` + SKU string `dynamodbav:"-"` + Name string `dynamodbav:"name"` + Price float64 `dynamodbav:"price"` + Currency string `dynamodbav:"currency"` + BillingCycle string `dynamodbav:"billingCycle"` + Features []string `dynamodbav:"features"` + Status string `dynamodbav:"status"` + LastModifiedAt string `dynamodbav:"lastModifiedAt"` +} diff --git a/app/go/src/handlers/subscription.go b/app/go/src/handlers/subscription.go new file mode 100644 index 0000000..1aa8f13 --- /dev/null +++ b/app/go/src/handlers/subscription.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/fenderdigital/fds-aws-coding-exercise/src/dtos" + "github.com/fenderdigital/fds-aws-coding-exercise/src/entities" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" + ddb "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]dtos.SubscriptionResponse, error) { + pk := "user:" + userID + out, err := ddbCli.Query(ctx, &ddb.QueryInput{ + TableName: aws.String(tableName), + KeyConditionExpression: aws.String("#pk = :pk"), + ExpressionAttributeNames: map[string]string{ + "#pk": "pk", + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":pk": &types.AttributeValueMemberS{Value: pk}, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to query user subscription: %w", err) + } + + subs := make([]entities.SubscriptionItem, 0, len(out.Items)) + for _, it := range out.Items { + var s entities.SubscriptionItem + if err := attributevalue.UnmarshalMap(it, &s); err != nil { + return nil, fmt.Errorf("failed to unmarshal subscription item: %w", err) + } + subs = append(subs, s) + } + + subResponses := make([]dtos.SubscriptionResponse, 0, len(subs)) + for _, sub := range subs { + plan, err := getPlan(ctx, ddbCli, tableName, sub.PlanSKU) + if err != nil { + return nil, err + } + + subID := strings.TrimPrefix(sub.SK, "sub:") + + status, err := getStatus(sub.CanceledAt, sub.ExpiresAt) + if err != nil { + return nil, err + } + + resp := dtos.SubscriptionResponse{ + UserID: strings.TrimPrefix(sub.PK, "user:"), + SubscriptionID: subID, + StartDate: sub.StartDate, + ExpiresAt: sub.ExpiresAt, + CancelledAt: sub.CanceledAt, + Status: status, + Attributes: sub.Attributes, + Plan: &dtos.SubscriptionResponsePlan{ + SKU: plan.SKU, + Name: plan.Name, + Price: plan.Price, + Currency: plan.Currency, + BillingCycle: plan.BillingCycle, + Features: plan.Features, + }, + } + + subResponses = append(subResponses, resp) + } + + return subResponses, nil +} + +func getPlan(ctx context.Context, ddbCli *ddb.Client, tableName, sku string) (*entities.Plan, error) { + pk, sk := planKey(sku) + out, err := ddbCli.GetItem(ctx, &ddb.GetItemInput{ + TableName: aws.String(tableName), + Key: map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: pk}, + "sk": &types.AttributeValueMemberS{Value: sk}, + }, + }) + if err != nil { + return nil, err + } + if out.Item == nil { + return nil, fmt.Errorf("plan %s not found", sku) + } + var p entities.Plan + if err := attributevalue.UnmarshalMap(out.Item, &p); err != nil { + return nil, err + } + + if strings.HasPrefix(p.PK, "plan:") { + p.SKU = strings.TrimPrefix(p.PK, "plan:") + } + return &p, nil +} + +func getStatus(canceledAt *string, expiresAt string) (dtos.SubStatus, error) { + exp, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return "", fmt.Errorf("invalid expiresAt parsing: %w", err) + } + if canceledAt == nil || *canceledAt == "" { + return dtos.SubStatusActive, nil + } + + if time.Now().Before(exp) { + return dtos.SubStatusPending, nil + } + + return dtos.SubStatusCancelled, nil +} + +func planKey(sku string) (pk, sk string) { + return "plan:" + sku, "meta" +} diff --git a/app/go/src/helpers.go b/app/go/src/helpers.go new file mode 100644 index 0000000..2f57f39 --- /dev/null +++ b/app/go/src/helpers.go @@ -0,0 +1,35 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/aws/aws-lambda-go/events" +) + +func parseJSON(v any) (events.APIGatewayProxyResponse, error) { + b, err := json.Marshal(v) + if err != nil { + return events.APIGatewayProxyResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) + } + return events.APIGatewayProxyResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: string(b), + }, nil +} + +func badRequest(msg string) events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{StatusCode: 400, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} +} + +func serverErr(err error) events.APIGatewayProxyResponse { + // Not configuring log libraries for now + log.Printf("server error: %v", err) + return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error":"internal error"}`} +} + +func notFound(msg string) events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{StatusCode: 404, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} +} diff --git a/app/go/src/main.go b/app/go/src/main.go index 590b390..9f258ab 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -2,20 +2,48 @@ package main import ( "context" + "strings" + "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + ddb "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/fenderdigital/fds-aws-coding-exercise/src/handlers" ) -type Response struct { - StatusCode int `json:"statusCode"` - Body string `json:"body"` +var ( + tableName string + ddbCli *ddb.Client +) + +func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + switch { + case req.HTTPMethod == "GET" && strings.HasPrefix(req.Path, "/api/v1/subscriptions/"): + userID := req.PathParameters["userId"] + /* + if userID == "" { + parts := strings.Split(req.Path, "/") + if len(parts) >= 5 { + userID = parts[4] + } + } + + */ + if userID == "" { + return badRequest("missing userId"), nil + } + return handleGetSubscription(ctx, userID) + default: + return notFound("route not found"), nil + } } -func handler(ctx context.Context, event interface{}) (Response, error) { - return Response{ - StatusCode: 200, - Body: "Hello from Go Lambda!", - }, nil +func handleGetSubscription(ctx context.Context, userID string) (events.APIGatewayProxyResponse, error) { + subs, err := handlers.GetUserSubs(ctx, ddbCli, tableName, userID) + if err != nil { + return serverErr(err), nil + } + + return parseJSON(subs) } func main() { From d5494442fe03d1ef977628ceb830e64efbef922c Mon Sep 17 00:00:00 2001 From: DanielGA Date: Wed, 22 Oct 2025 20:37:32 -0600 Subject: [PATCH 02/10] Add create subscription handler --- app/go/go.mod | 8 -- app/go/go.sum | 16 --- app/go/src/dtos/dtos.go | 14 +++ app/go/src/dtos/status.go | 8 +- app/go/src/handlers/subscription.go | 147 +++++++++++++++++++++++----- app/go/src/helpers.go | 9 ++ app/go/src/main.go | 21 +++- 7 files changed, 169 insertions(+), 54 deletions(-) diff --git a/app/go/go.mod b/app/go/go.mod index 37fa55a..2ea6283 100755 --- a/app/go/go.mod +++ b/app/go/go.mod @@ -5,23 +5,15 @@ go 1.24.5 require ( github.com/aws/aws-lambda-go v1.49.0 github.com/aws/aws-sdk-go-v2 v1.39.3 - github.com/aws/aws-sdk-go-v2/config v1.31.14 github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 ) require ( - github.com/aws/aws-sdk-go-v2/credentials v1.18.18 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 // indirect github.com/aws/smithy-go v1.23.1 // indirect ) diff --git a/app/go/go.sum b/app/go/go.sum index 9831d5c..b049202 100755 --- a/app/go/go.sum +++ b/app/go/go.sum @@ -2,20 +2,12 @@ github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYL github.com/aws/aws-lambda-go v1.49.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= -github.com/aws/aws-sdk-go-v2/config v1.31.14 h1:kj/KpDqvt0UqcEL3WOvCykE9QUpBb6b23hQdnXe+elo= -github.com/aws/aws-sdk-go-v2/config v1.31.14/go.mod h1:X5PaY6QCzViihn/ru7VxnIamcJQrG9NSeTxuSKm2YtU= -github.com/aws/aws-sdk-go-v2/credentials v1.18.18 h1:5AfxTvDN0AJoA7rg/yEc0sHhl6/B9fZ+NtiQuOjWGQM= -github.com/aws/aws-sdk-go-v2/credentials v1.18.18/go.mod h1:m9mE1mJ1s7zI6rrt7V3RQU2SCgUbNaphlfqEksLp+Fs= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 h1:IPznA4MUKCdHL28SZeuMhFxBSMTYZjm+lqLdQRKd6gM= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17/go.mod h1:OFWH4SmyLk8MzTWhk3XaveS3cNyY7SU6UQDPviMIEbM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 h1:mj/bdWleWEh81DtpdHKkw41IrS+r3uw1J/VQtbwYYp8= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10/go.mod h1:7+oEMxAZWP8gZCyjcm9VicI0M61Sx4DJtcGfKYv2yKQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 h1:wh+/mn57yhUrFtLIxyFPh2RgxgQz/u+Yrf7hiHGHqKY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10/go.mod h1:7zirD+ryp5gitJJ2m1BBux56ai8RIRDykXZrJSp540w= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 h1:HWdbTAAa51HIg4jXyTtkHRU5ZF0n3+rNChldmveicDw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1/go.mod h1:GyNGZUbiqJH5lMAVNlYlYXCNoJcCmyPAeLxlDKsmi1g= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 h1:AVmNRz6Sjfwug8mA314XbCOETbotDO1PtwZGk5bTy3I= @@ -24,14 +16,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 h1:T0QsDQNCVealR4CrVt+spgWJgjl8oIDje/5TH8YnCmE= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10/go.mod h1:SGBJMtnGk4y9Yvrr3iNPos9WUqexJHxq2OI6Z1ch634= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 h1:xSL4IV19pKDASL2fjWXRfTGmZddPiPPZNPpbv6uZQZY= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.8/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/app/go/src/dtos/dtos.go b/app/go/src/dtos/dtos.go index 675cde9..6620ee5 100644 --- a/app/go/src/dtos/dtos.go +++ b/app/go/src/dtos/dtos.go @@ -19,3 +19,17 @@ type SubscriptionResponsePlan struct { BillingCycle string `json:"billingCycle"` Features []string `json:"features"` } + +type SubscriptionRequest struct { + EventID string `json:"eventId"` + EventType string `json:"eventType"` + Timestamp string `json:"timestamp"` + Provider string `json:"provider"` + SubscriptionID string `json:"subscriptionId"` + PaymentID *string `json:"paymentId"` + UserID string `json:"userId"` + CustomerID string `json:"customerId"` + ExpiresAt string `json:"expiresAt"` + CanceledAt *string `json:"cancelledAt"` + Metadata map[string]any `json:"metadata"` +} diff --git a/app/go/src/dtos/status.go b/app/go/src/dtos/status.go index e428bee..9c45a7d 100644 --- a/app/go/src/dtos/status.go +++ b/app/go/src/dtos/status.go @@ -3,8 +3,8 @@ package dtos type SubStatus string const ( - SubStatusActive SubStatus = "ACTIVE" - SubStatusInactive SubStatus = "INACTIVE" - SubStatusPending SubStatus = "PENDING" - SubStatusCancelled SubStatus = "CANCELLED" + SubStatusActive SubStatus = "active" + SubStatusInactive SubStatus = "inactive" + SubStatusPending SubStatus = "pending" + SubStatusCancelled SubStatus = "cancelled" ) diff --git a/app/go/src/handlers/subscription.go b/app/go/src/handlers/subscription.go index 1aa8f13..3eb5758 100644 --- a/app/go/src/handlers/subscription.go +++ b/app/go/src/handlers/subscription.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "errors" "fmt" "strings" "time" @@ -15,32 +16,18 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" ) -func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]dtos.SubscriptionResponse, error) { - pk := "user:" + userID - out, err := ddbCli.Query(ctx, &ddb.QueryInput{ - TableName: aws.String(tableName), - KeyConditionExpression: aws.String("#pk = :pk"), - ExpressionAttributeNames: map[string]string{ - "#pk": "pk", - }, - ExpressionAttributeValues: map[string]types.AttributeValue{ - ":pk": &types.AttributeValueMemberS{Value: pk}, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to query user subscription: %w", err) - } +var ( + errActivePlan = errors.New("plan is active") + errActiveOrPendingSub = errors.New("user already has an active/pending subscription") +) - subs := make([]entities.SubscriptionItem, 0, len(out.Items)) - for _, it := range out.Items { - var s entities.SubscriptionItem - if err := attributevalue.UnmarshalMap(it, &s); err != nil { - return nil, fmt.Errorf("failed to unmarshal subscription item: %w", err) - } - subs = append(subs, s) +func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]*dtos.SubscriptionResponse, error) { + subs, err := getUserSubs(ctx, ddbCli, tableName, userID) + if err != nil { + return nil, err } - subResponses := make([]dtos.SubscriptionResponse, 0, len(subs)) + subResponses := make([]*dtos.SubscriptionResponse, 0, len(subs)) for _, sub := range subs { plan, err := getPlan(ctx, ddbCli, tableName, sub.PlanSKU) if err != nil { @@ -72,12 +59,97 @@ func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID stri }, } - subResponses = append(subResponses, resp) + subResponses = append(subResponses, &resp) } return subResponses, nil } +func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, subReq *dtos.SubscriptionRequest) error { + plan, err := getPlan(ctx, ddbCli, tableName, asString(subReq.Metadata["planSku"])) + if err != nil { + return err + } + + if strings.ToLower(plan.Status) != "active" { + return errActivePlan + } + + subs, err := getUserSubs(ctx, ddbCli, tableName, subReq.UserID) + if err != nil { + return err + } + + isActiveOrPending, err := hasActiveOrPending(subs) + if err != nil { + return err + } + + if isActiveOrPending { + return errActiveOrPendingSub + } + + pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) + start := subReq.Timestamp + planSKU := asString(subReq.Metadata["planSku"]) + + delete(subReq.Metadata, "planSku") + item := entities.SubscriptionItem{ + PK: pk, + SK: sk, + Type: "sub", + PlanSKU: planSKU, + StartDate: start, + ExpiresAt: subReq.ExpiresAt, + CanceledAt: nil, + LastModifiedAt: time.Now().Format(time.RFC3339), + Attributes: subReq.Metadata, + } + av, err := attributevalue.MarshalMap(item) + if err != nil { + return fmt.Errorf("failed to marshal item: %w", err) + } + + _, err = ddbCli.PutItem(ctx, &ddb.PutItemInput{ + TableName: aws.String(tableName), + Item: av, + ConditionExpression: aws.String("attribute_not_exists(pk) AND attribute_not_exists(sk)"), + }) + if err != nil { + return fmt.Errorf("failed to insert subscription item: %w", err) + } + + return nil +} + +func getUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]*entities.SubscriptionItem, error) { + pk := "user:" + userID + out, err := ddbCli.Query(ctx, &ddb.QueryInput{ + TableName: aws.String(tableName), + KeyConditionExpression: aws.String("#pk = :pk"), + ExpressionAttributeNames: map[string]string{ + "#pk": "pk", + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":pk": &types.AttributeValueMemberS{Value: pk}, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to query user subscription: %w", err) + } + + subs := make([]*entities.SubscriptionItem, 0, len(out.Items)) + for _, it := range out.Items { + var s entities.SubscriptionItem + if err := attributevalue.UnmarshalMap(it, &s); err != nil { + return nil, fmt.Errorf("failed to unmarshal subscription item: %w", err) + } + subs = append(subs, &s) + } + + return subs, nil +} + func getPlan(ctx context.Context, ddbCli *ddb.Client, tableName, sku string) (*entities.Plan, error) { pk, sk := planKey(sku) out, err := ddbCli.GetItem(ctx, &ddb.GetItemInput{ @@ -123,3 +195,30 @@ func getStatus(canceledAt *string, expiresAt string) (dtos.SubStatus, error) { func planKey(sku string) (pk, sk string) { return "plan:" + sku, "meta" } + +func hasActiveOrPending(subs []*entities.SubscriptionItem) (bool, error) { + for _, s := range subs { + st, err := getStatus(s.CanceledAt, s.ExpiresAt) + if err != nil { + return false, err + } + if st == dtos.SubStatusActive || st == dtos.SubStatusPending { + return true, nil + } + } + return false, nil +} + +func asString(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} + +func userSubKey(userID, subID string) (pk, sk string) { + return "user:" + userID, "sub:" + subID +} diff --git a/app/go/src/helpers.go b/app/go/src/helpers.go index 2f57f39..f9c4b09 100644 --- a/app/go/src/helpers.go +++ b/app/go/src/helpers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "strings" "github.com/aws/aws-lambda-go/events" ) @@ -33,3 +34,11 @@ func serverErr(err error) events.APIGatewayProxyResponse { func notFound(msg string) events.APIGatewayProxyResponse { return events.APIGatewayProxyResponse{StatusCode: 404, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} } + +func requestErr(err error) events.APIGatewayProxyResponse { + // Maybe not the best error handling for now, update later to use errors.Is/errors.As if possible :) + if strings.Contains(err.Error(), "active/pending") { + return events.APIGatewayProxyResponse{StatusCode: 409, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} + } + return events.APIGatewayProxyResponse{StatusCode: 422, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} +} diff --git a/app/go/src/main.go b/app/go/src/main.go index 9f258ab..04b86f7 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -2,11 +2,13 @@ package main import ( "context" + "encoding/json" "strings" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" ddb "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/fenderdigital/fds-aws-coding-exercise/src/dtos" "github.com/fenderdigital/fds-aws-coding-exercise/src/handlers" ) @@ -32,9 +34,18 @@ func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.API return badRequest("missing userId"), nil } return handleGetSubscription(ctx, userID) - default: - return notFound("route not found"), nil + case req.HTTPMethod == "POST" && req.Path == "/api/v1/webhooks/subscriptions": + var subEventReq dtos.SubscriptionRequest + if err := json.Unmarshal([]byte(req.Body), &subEventReq); err != nil { + return badRequest(err.Error()), nil + } + switch subEventReq.EventType { + case "subscription.created": + return handleCreateSubscription(ctx, &subEventReq) + } } + + return notFound("route not found"), nil } func handleGetSubscription(ctx context.Context, userID string) (events.APIGatewayProxyResponse, error) { @@ -46,6 +57,12 @@ func handleGetSubscription(ctx context.Context, userID string) (events.APIGatewa return parseJSON(subs) } +func handleCreateSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := handlers.CreateUserSub(ctx, ddbCli, tableName, req) + + return requestErr(err), nil +} + func main() { lambda.Start(handler) } From 13cf309969cbcf37106ea3a357a551355865d042 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Wed, 22 Oct 2025 21:05:32 -0600 Subject: [PATCH 03/10] Add initial function and tidy dependencies --- app/go/go.mod | 8 ++++++++ app/go/go.sum | 16 ++++++++++++++++ app/go/src/main.go | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/app/go/go.mod b/app/go/go.mod index 2ea6283..37fa55a 100755 --- a/app/go/go.mod +++ b/app/go/go.mod @@ -5,15 +5,23 @@ go 1.24.5 require ( github.com/aws/aws-lambda-go v1.49.0 github.com/aws/aws-sdk-go-v2 v1.39.3 + github.com/aws/aws-sdk-go-v2/config v1.31.14 github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 ) require ( + github.com/aws/aws-sdk-go-v2/credentials v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 // indirect github.com/aws/smithy-go v1.23.1 // indirect ) diff --git a/app/go/go.sum b/app/go/go.sum index b049202..9831d5c 100755 --- a/app/go/go.sum +++ b/app/go/go.sum @@ -2,12 +2,20 @@ github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYL github.com/aws/aws-lambda-go v1.49.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/config v1.31.14 h1:kj/KpDqvt0UqcEL3WOvCykE9QUpBb6b23hQdnXe+elo= +github.com/aws/aws-sdk-go-v2/config v1.31.14/go.mod h1:X5PaY6QCzViihn/ru7VxnIamcJQrG9NSeTxuSKm2YtU= +github.com/aws/aws-sdk-go-v2/credentials v1.18.18 h1:5AfxTvDN0AJoA7rg/yEc0sHhl6/B9fZ+NtiQuOjWGQM= +github.com/aws/aws-sdk-go-v2/credentials v1.18.18/go.mod h1:m9mE1mJ1s7zI6rrt7V3RQU2SCgUbNaphlfqEksLp+Fs= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 h1:IPznA4MUKCdHL28SZeuMhFxBSMTYZjm+lqLdQRKd6gM= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17/go.mod h1:OFWH4SmyLk8MzTWhk3XaveS3cNyY7SU6UQDPviMIEbM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 h1:mj/bdWleWEh81DtpdHKkw41IrS+r3uw1J/VQtbwYYp8= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10/go.mod h1:7+oEMxAZWP8gZCyjcm9VicI0M61Sx4DJtcGfKYv2yKQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 h1:wh+/mn57yhUrFtLIxyFPh2RgxgQz/u+Yrf7hiHGHqKY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10/go.mod h1:7zirD+ryp5gitJJ2m1BBux56ai8RIRDykXZrJSp540w= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 h1:HWdbTAAa51HIg4jXyTtkHRU5ZF0n3+rNChldmveicDw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1/go.mod h1:GyNGZUbiqJH5lMAVNlYlYXCNoJcCmyPAeLxlDKsmi1g= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 h1:AVmNRz6Sjfwug8mA314XbCOETbotDO1PtwZGk5bTy3I= @@ -16,6 +24,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 h1:T0QsDQNCVealR4CrVt+spgWJgjl8oIDje/5TH8YnCmE= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10/go.mod h1:SGBJMtnGk4y9Yvrr3iNPos9WUqexJHxq2OI6Z1ch634= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 h1:xSL4IV19pKDASL2fjWXRfTGmZddPiPPZNPpbv6uZQZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.8/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/app/go/src/main.go b/app/go/src/main.go index 04b86f7..996a2bf 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -3,10 +3,14 @@ package main import ( "context" "encoding/json" + "fmt" + "log" + "os" "strings" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" ddb "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/fenderdigital/fds-aws-coding-exercise/src/dtos" "github.com/fenderdigital/fds-aws-coding-exercise/src/handlers" @@ -17,6 +21,22 @@ var ( ddbCli *ddb.Client ) +func NewFromEnv(ctx context.Context) error { + tableName = os.Getenv("DDB_TABLE") + if tableName == "" { + return fmt.Errorf("DDB_TABLE is required") + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return fmt.Errorf("load aws config: %w", err) + } + + ddbCli = ddb.NewFromConfig(cfg) + + return nil +} + func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { switch { case req.HTTPMethod == "GET" && strings.HasPrefix(req.Path, "/api/v1/subscriptions/"): @@ -64,5 +84,10 @@ func handleCreateSubscription(ctx context.Context, req *dtos.SubscriptionRequest } func main() { + ctx := context.Background() + err := NewFromEnv(ctx) + if err != nil { + log.Fatal(err) + } lambda.Start(handler) } From 507c8f1878c92a5854ee440128e9117f57166706 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Wed, 22 Oct 2025 21:10:38 -0600 Subject: [PATCH 04/10] Move helpers to main file --- app/go/src/helpers.go | 44 ------------------------------------------- app/go/src/main.go | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 44 deletions(-) delete mode 100644 app/go/src/helpers.go diff --git a/app/go/src/helpers.go b/app/go/src/helpers.go deleted file mode 100644 index f9c4b09..0000000 --- a/app/go/src/helpers.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "strings" - - "github.com/aws/aws-lambda-go/events" -) - -func parseJSON(v any) (events.APIGatewayProxyResponse, error) { - b, err := json.Marshal(v) - if err != nil { - return events.APIGatewayProxyResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) - } - return events.APIGatewayProxyResponse{ - StatusCode: 200, - Headers: map[string]string{"Content-Type": "application/json"}, - Body: string(b), - }, nil -} - -func badRequest(msg string) events.APIGatewayProxyResponse { - return events.APIGatewayProxyResponse{StatusCode: 400, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} -} - -func serverErr(err error) events.APIGatewayProxyResponse { - // Not configuring log libraries for now - log.Printf("server error: %v", err) - return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error":"internal error"}`} -} - -func notFound(msg string) events.APIGatewayProxyResponse { - return events.APIGatewayProxyResponse{StatusCode: 404, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} -} - -func requestErr(err error) events.APIGatewayProxyResponse { - // Maybe not the best error handling for now, update later to use errors.Is/errors.As if possible :) - if strings.Contains(err.Error(), "active/pending") { - return events.APIGatewayProxyResponse{StatusCode: 409, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} - } - return events.APIGatewayProxyResponse{StatusCode: 422, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} -} diff --git a/app/go/src/main.go b/app/go/src/main.go index 996a2bf..ddc82e0 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -91,3 +91,37 @@ func main() { } lambda.Start(handler) } + +func parseJSON(v any) (events.APIGatewayProxyResponse, error) { + b, err := json.Marshal(v) + if err != nil { + return events.APIGatewayProxyResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) + } + return events.APIGatewayProxyResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: string(b), + }, nil +} + +func badRequest(msg string) events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{StatusCode: 400, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} +} + +func serverErr(err error) events.APIGatewayProxyResponse { + // Not configuring log libraries for now + log.Printf("server error: %v", err) + return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error":"internal error"}`} +} + +func notFound(msg string) events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{StatusCode: 404, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} +} + +func requestErr(err error) events.APIGatewayProxyResponse { + // Maybe not the best error handling for now, update later to use errors.Is/errors.As if possible :) + if strings.Contains(err.Error(), "active/pending") { + return events.APIGatewayProxyResponse{StatusCode: 409, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} + } + return events.APIGatewayProxyResponse{StatusCode: 422, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} +} From 33211ef8f06831d622429a841c6ff2adee1d0404 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Thu, 23 Oct 2025 12:58:11 -0600 Subject: [PATCH 05/10] Add renew and cancel subscription handlers --- app/go/src/handlers/subscription.go | 64 +++++++++++++++++++++++++++-- app/go/src/main.go | 44 ++++++++++++++------ 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/app/go/src/handlers/subscription.go b/app/go/src/handlers/subscription.go index 3eb5758..ded62aa 100644 --- a/app/go/src/handlers/subscription.go +++ b/app/go/src/handlers/subscription.go @@ -19,6 +19,7 @@ import ( var ( errActivePlan = errors.New("plan is active") errActiveOrPendingSub = errors.New("user already has an active/pending subscription") + errMissingCanceledAt = errors.New("missing canceledAt time to cancel subscription") ) func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]*dtos.SubscriptionResponse, error) { @@ -80,12 +81,12 @@ func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su return err } - isActiveOrPending, err := hasActiveOrPending(subs) + hasActiveOrPendingSub, err := hasActiveOrPending(subs) if err != nil { return err } - if isActiveOrPending { + if hasActiveOrPendingSub { return errActiveOrPendingSub } @@ -116,7 +117,56 @@ func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su ConditionExpression: aws.String("attribute_not_exists(pk) AND attribute_not_exists(sk)"), }) if err != nil { - return fmt.Errorf("failed to insert subscription item: %w", err) + return fmt.Errorf("failed to create subscription: %w", err) + } + + return nil +} + +func RenewUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, subReq *dtos.SubscriptionRequest) error { + pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) + _, err := ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ + TableName: aws.String(tableName), + Key: map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: pk}, + "sk": &types.AttributeValueMemberS{Value: sk}, + }, + UpdateExpression: aws.String("SET expiresAt = :exp, lastModifiedAt = :now, attributes = :attrs REMOVE canceledAt"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":exp": &types.AttributeValueMemberS{Value: subReq.ExpiresAt}, + ":now": &types.AttributeValueMemberS{Value: time.Now().Format(time.RFC3339)}, + ":attrs": parseMetadataAttributes(subReq.Metadata), + }, + ConditionExpression: aws.String("attribute_exists(pk) AND attribute_exists(sk)"), + }) + if err != nil { + return fmt.Errorf("failed to renew subscription: %w", err) + } + + return nil +} + +func CancelUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, subReq *dtos.SubscriptionRequest) error { + if subReq.CanceledAt == nil || *subReq.CanceledAt == "" { + return errMissingCanceledAt + } + pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) + _, err := ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ + TableName: aws.String(tableName), + Key: map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: pk}, + "sk": &types.AttributeValueMemberS{Value: sk}, + }, + UpdateExpression: aws.String("SET cancelledAt = :canceled, lastModifiedAt = :now, attributes = :attrs"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":canceled": &types.AttributeValueMemberS{Value: *subReq.CanceledAt}, + ":now": &types.AttributeValueMemberS{Value: time.Now().Format(time.RFC3339)}, + ":attrs": parseMetadataAttributes(subReq.Metadata), + }, + ConditionExpression: aws.String("attribute_exists(pk) AND attribute_exists(sk)"), + }) + if err != nil { + return fmt.Errorf("failed to cancel subscription: %w", err) } return nil @@ -222,3 +272,11 @@ func asString(v any) string { func userSubKey(userID, subID string) (pk, sk string) { return "user:" + userID, "sub:" + subID } + +func parseMetadataAttributes(m map[string]any) types.AttributeValue { + av, err := attributevalue.Marshal(m) + if err != nil { + return &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{}} + } + return av +} diff --git a/app/go/src/main.go b/app/go/src/main.go index ddc82e0..a7c869b 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "net/http" "os" "strings" @@ -41,15 +42,6 @@ func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.API switch { case req.HTTPMethod == "GET" && strings.HasPrefix(req.Path, "/api/v1/subscriptions/"): userID := req.PathParameters["userId"] - /* - if userID == "" { - parts := strings.Split(req.Path, "/") - if len(parts) >= 5 { - userID = parts[4] - } - } - - */ if userID == "" { return badRequest("missing userId"), nil } @@ -62,6 +54,12 @@ func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.API switch subEventReq.EventType { case "subscription.created": return handleCreateSubscription(ctx, &subEventReq) + case "subscription.renewed": + return handleRenewSubscription(ctx, &subEventReq) + case "subscription.cancelled": + return handleCancelSubscription(ctx, &subEventReq) + default: + return badRequest("unknown event type"), nil } } @@ -74,13 +72,32 @@ func handleGetSubscription(ctx context.Context, userID string) (events.APIGatewa return serverErr(err), nil } - return parseJSON(subs) + return parseJSON(http.StatusOK, subs) } func handleCreateSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { err := handlers.CreateUserSub(ctx, ddbCli, tableName, req) + if err != nil { + return serverErr(err), nil + } + return parseJSON(http.StatusCreated, map[string]string{"status": "created"}) +} + +func handleRenewSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := handlers.RenewUserSub(ctx, ddbCli, tableName, req) + if err != nil { + return serverErr(err), nil + } - return requestErr(err), nil + return parseJSON(http.StatusOK, map[string]string{"status": "renewed"}) +} + +func handleCancelSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := handlers.CancelUserSub(ctx, ddbCli, tableName, req) + if err != nil { + return serverErr(err), nil + } + return parseJSON(http.StatusOK, map[string]string{"status": "cancelled"}) } func main() { @@ -92,13 +109,14 @@ func main() { lambda.Start(handler) } -func parseJSON(v any) (events.APIGatewayProxyResponse, error) { +func parseJSON(code int, v any) (events.APIGatewayProxyResponse, error) { b, err := json.Marshal(v) if err != nil { return events.APIGatewayProxyResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) } + return events.APIGatewayProxyResponse{ - StatusCode: 200, + StatusCode: code, Headers: map[string]string{"Content-Type": "application/json"}, Body: string(b), }, nil From f9933df8691d48063ddea8d1280618c89fcc4e9b Mon Sep 17 00:00:00 2001 From: DanielGA Date: Thu, 23 Oct 2025 13:12:25 -0600 Subject: [PATCH 06/10] Fix cancelled at misspelling --- app/go/src/entities/subscription.go | 2 +- app/go/src/handlers/subscription.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/go/src/entities/subscription.go b/app/go/src/entities/subscription.go index d720c13..4c2ebdb 100644 --- a/app/go/src/entities/subscription.go +++ b/app/go/src/entities/subscription.go @@ -7,7 +7,7 @@ type SubscriptionItem struct { PlanSKU string `dynamodbav:"planSku"` StartDate string `dynamodbav:"startDate"` ExpiresAt string `dynamodbav:"expiresAt"` - CanceledAt *string `dynamodbav:"canceledAt"` + CancelledAt *string `dynamodbav:"cancelledAt"` LastModifiedAt string `dynamodbav:"lastModifiedAt"` Attributes map[string]interface{} `dynamodbav:"attributes"` } diff --git a/app/go/src/handlers/subscription.go b/app/go/src/handlers/subscription.go index ded62aa..53ae09e 100644 --- a/app/go/src/handlers/subscription.go +++ b/app/go/src/handlers/subscription.go @@ -37,7 +37,7 @@ func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID stri subID := strings.TrimPrefix(sub.SK, "sub:") - status, err := getStatus(sub.CanceledAt, sub.ExpiresAt) + status, err := getStatus(sub.CancelledAt, sub.ExpiresAt) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID stri SubscriptionID: subID, StartDate: sub.StartDate, ExpiresAt: sub.ExpiresAt, - CancelledAt: sub.CanceledAt, + CancelledAt: sub.CancelledAt, Status: status, Attributes: sub.Attributes, Plan: &dtos.SubscriptionResponsePlan{ @@ -102,7 +102,7 @@ func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su PlanSKU: planSKU, StartDate: start, ExpiresAt: subReq.ExpiresAt, - CanceledAt: nil, + CancelledAt: nil, LastModifiedAt: time.Now().Format(time.RFC3339), Attributes: subReq.Metadata, } @@ -248,7 +248,7 @@ func planKey(sku string) (pk, sk string) { func hasActiveOrPending(subs []*entities.SubscriptionItem) (bool, error) { for _, s := range subs { - st, err := getStatus(s.CanceledAt, s.ExpiresAt) + st, err := getStatus(s.CancelledAt, s.ExpiresAt) if err != nil { return false, err } From abb7ac8662c7e7aaa523dd5f21f489d22d0eb8a9 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Thu, 23 Oct 2025 13:28:29 -0600 Subject: [PATCH 07/10] Add lambda and api handler structs --- app/go/src/handlers/subscription.go | 49 +++++++++++++++++------------ app/go/src/main.go | 49 ++++++++++++++--------------- 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/app/go/src/handlers/subscription.go b/app/go/src/handlers/subscription.go index 53ae09e..957273d 100644 --- a/app/go/src/handlers/subscription.go +++ b/app/go/src/handlers/subscription.go @@ -22,15 +22,24 @@ var ( errMissingCanceledAt = errors.New("missing canceledAt time to cancel subscription") ) -func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]*dtos.SubscriptionResponse, error) { - subs, err := getUserSubs(ctx, ddbCli, tableName, userID) +type ApiHandler struct { + tableName string + ddbCli *ddb.Client +} + +func NewApiHandler(tableName string, ddbCli *ddb.Client) *ApiHandler { + return &ApiHandler{tableName: tableName, ddbCli: ddbCli} +} + +func (ah *ApiHandler) GetUserSubs(ctx context.Context, userID string) ([]*dtos.SubscriptionResponse, error) { + subs, err := ah.getUserSubs(ctx, userID) if err != nil { return nil, err } subResponses := make([]*dtos.SubscriptionResponse, 0, len(subs)) for _, sub := range subs { - plan, err := getPlan(ctx, ddbCli, tableName, sub.PlanSKU) + plan, err := ah.getPlan(ctx, sub.PlanSKU) if err != nil { return nil, err } @@ -66,8 +75,8 @@ func GetUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID stri return subResponses, nil } -func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, subReq *dtos.SubscriptionRequest) error { - plan, err := getPlan(ctx, ddbCli, tableName, asString(subReq.Metadata["planSku"])) +func (ah *ApiHandler) CreateUserSub(ctx context.Context, subReq *dtos.SubscriptionRequest) error { + plan, err := ah.getPlan(ctx, asString(subReq.Metadata["planSku"])) if err != nil { return err } @@ -76,7 +85,7 @@ func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su return errActivePlan } - subs, err := getUserSubs(ctx, ddbCli, tableName, subReq.UserID) + subs, err := ah.getUserSubs(ctx, subReq.UserID) if err != nil { return err } @@ -111,8 +120,8 @@ func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su return fmt.Errorf("failed to marshal item: %w", err) } - _, err = ddbCli.PutItem(ctx, &ddb.PutItemInput{ - TableName: aws.String(tableName), + _, err = ah.ddbCli.PutItem(ctx, &ddb.PutItemInput{ + TableName: aws.String(ah.tableName), Item: av, ConditionExpression: aws.String("attribute_not_exists(pk) AND attribute_not_exists(sk)"), }) @@ -123,10 +132,10 @@ func CreateUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su return nil } -func RenewUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, subReq *dtos.SubscriptionRequest) error { +func (ah *ApiHandler) RenewUserSub(ctx context.Context, subReq *dtos.SubscriptionRequest) error { pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) - _, err := ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ - TableName: aws.String(tableName), + _, err := ah.ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ + TableName: aws.String(ah.tableName), Key: map[string]types.AttributeValue{ "pk": &types.AttributeValueMemberS{Value: pk}, "sk": &types.AttributeValueMemberS{Value: sk}, @@ -146,13 +155,13 @@ func RenewUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, sub return nil } -func CancelUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, subReq *dtos.SubscriptionRequest) error { +func (ah *ApiHandler) CancelUserSub(ctx context.Context, subReq *dtos.SubscriptionRequest) error { if subReq.CanceledAt == nil || *subReq.CanceledAt == "" { return errMissingCanceledAt } pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) - _, err := ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ - TableName: aws.String(tableName), + _, err := ah.ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ + TableName: aws.String(ah.tableName), Key: map[string]types.AttributeValue{ "pk": &types.AttributeValueMemberS{Value: pk}, "sk": &types.AttributeValueMemberS{Value: sk}, @@ -172,10 +181,10 @@ func CancelUserSub(ctx context.Context, ddbCli *ddb.Client, tableName string, su return nil } -func getUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID string) ([]*entities.SubscriptionItem, error) { +func (ah *ApiHandler) getUserSubs(ctx context.Context, userID string) ([]*entities.SubscriptionItem, error) { pk := "user:" + userID - out, err := ddbCli.Query(ctx, &ddb.QueryInput{ - TableName: aws.String(tableName), + out, err := ah.ddbCli.Query(ctx, &ddb.QueryInput{ + TableName: aws.String(ah.tableName), KeyConditionExpression: aws.String("#pk = :pk"), ExpressionAttributeNames: map[string]string{ "#pk": "pk", @@ -200,10 +209,10 @@ func getUserSubs(ctx context.Context, ddbCli *ddb.Client, tableName, userID stri return subs, nil } -func getPlan(ctx context.Context, ddbCli *ddb.Client, tableName, sku string) (*entities.Plan, error) { +func (ah *ApiHandler) getPlan(ctx context.Context, sku string) (*entities.Plan, error) { pk, sk := planKey(sku) - out, err := ddbCli.GetItem(ctx, &ddb.GetItemInput{ - TableName: aws.String(tableName), + out, err := ah.ddbCli.GetItem(ctx, &ddb.GetItemInput{ + TableName: aws.String(ah.tableName), Key: map[string]types.AttributeValue{ "pk": &types.AttributeValueMemberS{Value: pk}, "sk": &types.AttributeValueMemberS{Value: sk}, diff --git a/app/go/src/main.go b/app/go/src/main.go index a7c869b..abddd8a 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -17,35 +17,32 @@ import ( "github.com/fenderdigital/fds-aws-coding-exercise/src/handlers" ) -var ( - tableName string - ddbCli *ddb.Client -) +type LambdaHandler struct { + apiHandler *handlers.ApiHandler +} -func NewFromEnv(ctx context.Context) error { - tableName = os.Getenv("DDB_TABLE") +func NewLambdaHandler(ctx context.Context) (*LambdaHandler, error) { + tableName := os.Getenv("DDB_TABLE") if tableName == "" { - return fmt.Errorf("DDB_TABLE is required") + return nil, fmt.Errorf("DDB_TABLE is required") } cfg, err := config.LoadDefaultConfig(ctx) if err != nil { - return fmt.Errorf("load aws config: %w", err) + return nil, fmt.Errorf("load aws config: %w", err) } - ddbCli = ddb.NewFromConfig(cfg) - - return nil + return &LambdaHandler{apiHandler: handlers.NewApiHandler(tableName, ddb.NewFromConfig(cfg))}, nil } -func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { +func (lh *LambdaHandler) handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { switch { case req.HTTPMethod == "GET" && strings.HasPrefix(req.Path, "/api/v1/subscriptions/"): userID := req.PathParameters["userId"] if userID == "" { return badRequest("missing userId"), nil } - return handleGetSubscription(ctx, userID) + return lh.handleGetSubscription(ctx, userID) case req.HTTPMethod == "POST" && req.Path == "/api/v1/webhooks/subscriptions": var subEventReq dtos.SubscriptionRequest if err := json.Unmarshal([]byte(req.Body), &subEventReq); err != nil { @@ -53,11 +50,11 @@ func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.API } switch subEventReq.EventType { case "subscription.created": - return handleCreateSubscription(ctx, &subEventReq) + return lh.handleCreateSubscription(ctx, &subEventReq) case "subscription.renewed": - return handleRenewSubscription(ctx, &subEventReq) + return lh.handleRenewSubscription(ctx, &subEventReq) case "subscription.cancelled": - return handleCancelSubscription(ctx, &subEventReq) + return lh.handleCancelSubscription(ctx, &subEventReq) default: return badRequest("unknown event type"), nil } @@ -66,8 +63,8 @@ func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.API return notFound("route not found"), nil } -func handleGetSubscription(ctx context.Context, userID string) (events.APIGatewayProxyResponse, error) { - subs, err := handlers.GetUserSubs(ctx, ddbCli, tableName, userID) +func (lh *LambdaHandler) handleGetSubscription(ctx context.Context, userID string) (events.APIGatewayProxyResponse, error) { + subs, err := lh.apiHandler.GetUserSubs(ctx, userID) if err != nil { return serverErr(err), nil } @@ -75,16 +72,16 @@ func handleGetSubscription(ctx context.Context, userID string) (events.APIGatewa return parseJSON(http.StatusOK, subs) } -func handleCreateSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { - err := handlers.CreateUserSub(ctx, ddbCli, tableName, req) +func (lh *LambdaHandler) handleCreateSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := lh.apiHandler.CreateUserSub(ctx, req) if err != nil { return serverErr(err), nil } return parseJSON(http.StatusCreated, map[string]string{"status": "created"}) } -func handleRenewSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { - err := handlers.RenewUserSub(ctx, ddbCli, tableName, req) +func (lh *LambdaHandler) handleRenewSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := lh.apiHandler.RenewUserSub(ctx, req) if err != nil { return serverErr(err), nil } @@ -92,8 +89,8 @@ func handleRenewSubscription(ctx context.Context, req *dtos.SubscriptionRequest) return parseJSON(http.StatusOK, map[string]string{"status": "renewed"}) } -func handleCancelSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { - err := handlers.CancelUserSub(ctx, ddbCli, tableName, req) +func (lh *LambdaHandler) handleCancelSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := lh.apiHandler.CancelUserSub(ctx, req) if err != nil { return serverErr(err), nil } @@ -102,11 +99,11 @@ func handleCancelSubscription(ctx context.Context, req *dtos.SubscriptionRequest func main() { ctx := context.Background() - err := NewFromEnv(ctx) + lHandler, err := NewLambdaHandler(ctx) if err != nil { log.Fatal(err) } - lambda.Start(handler) + lambda.Start(lHandler.handler) } func parseJSON(code int, v any) (events.APIGatewayProxyResponse, error) { From a89ffbabc6cc0bf6a079a46e9ce80d3b90a6d3b9 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Thu, 23 Oct 2025 13:32:43 -0600 Subject: [PATCH 08/10] Remove unused status --- app/go/src/dtos/status.go | 1 - 1 file changed, 1 deletion(-) diff --git a/app/go/src/dtos/status.go b/app/go/src/dtos/status.go index 9c45a7d..db4554b 100644 --- a/app/go/src/dtos/status.go +++ b/app/go/src/dtos/status.go @@ -4,7 +4,6 @@ type SubStatus string const ( SubStatusActive SubStatus = "active" - SubStatusInactive SubStatus = "inactive" SubStatusPending SubStatus = "pending" SubStatusCancelled SubStatus = "cancelled" ) From 6189d75e6bb04ec8199a5d5e3e5cdff4452ad502 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Thu, 23 Oct 2025 13:53:58 -0600 Subject: [PATCH 09/10] Add postman E2E tests --- app/go/e2e/E2E_postman_tests.json | 177 ++++++++++++++++++++++++++++ app/go/src/entities/subscription.go | 18 +-- 2 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 app/go/e2e/E2E_postman_tests.json diff --git a/app/go/e2e/E2E_postman_tests.json b/app/go/e2e/E2E_postman_tests.json new file mode 100644 index 0000000..aa7e7ce --- /dev/null +++ b/app/go/e2e/E2E_postman_tests.json @@ -0,0 +1,177 @@ +{ + "info": { + "name": "Fender development Exercise", + "description": "E2E test Postman", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Create subscription", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base}}/api/v1/webhooks/subscriptions", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "subscriptions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"eventId\": \"evt_123456789\", \"eventType\": \"subscription.created\", \"timestamp\": \"2024-03-20T10:00:00Z\", \"provider\": \"STRIPE\", \"subscriptionId\": \"sub_456789\", \"paymentId\": \"pm_123456\", \"userId\": \"123\", \"customerId\": \"cus_789012\", \"expiresAt\": \"2024-04-20T10:00:00Z\", \"metadata\": {\"planSku\": \"PREMIUM_MONTHLY\", \"autoRenew\": true, \"paymentMethod\": \"CREDIT_CARD\"}}" + } + } + }, + { + "name": "Get subscription (after create)", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{base}}/api/v1/subscriptions/123", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "subscriptions", + "123" + ] + } + } + }, + { + "name": "Renew subscription", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base}}/api/v1/webhooks/subscriptions", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "subscriptions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"eventId\": \"evt_123456789\", \"eventType\": \"subscription.renewed\", \"timestamp\": \"2024-04-20T10:00:00Z\", \"provider\": \"STRIPE\", \"subscriptionId\": \"sub_456789\", \"paymentId\": \"pm_122334\", \"userId\": \"123\", \"customerId\": \"cus_789012\", \"expiresAt\": \"2024-05-20T10:00:00Z\", \"metadata\": {\"planSku\": \"PREMIUM_MONTHLY\", \"autoRenew\": true, \"paymentMethod\": \"CREDIT_CARD\"}}" + } + } + }, + { + "name": "Get subscription (after renew)", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{base}}/api/v1/subscriptions/123", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "subscriptions", + "123" + ] + } + } + }, + { + "name": "Cancel subscription", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base}}/api/v1/webhooks/subscriptions", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "subscriptions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"eventId\": \"evt_456789123\", \"eventType\": \"subscription.cancelled\", \"timestamp\": \"2024-05-20T10:00:00Z\", \"provider\": \"STRIPE\", \"subscriptionId\": \"sub_456789\", \"paymentId\": null, \"userId\": \"123\", \"customerId\": \"cus_789012\", \"expiresAt\": \"2024-05-20T10:00:00Z\", \"cancelledAt\": \"2024-05-20T10:00:00Z\", \"metadata\": {\"planSku\": \"PREMIUM_MONTHLY\", \"autoRenew\": false, \"paymentMethod\": \"CREDIT_CARD\", \"cancelReason\": \"USER_REQUESTED\"}}" + } + } + }, + { + "name": "Get subscription (after cancel)", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{base}}/api/v1/subscriptions/123", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "subscriptions", + "123" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/app/go/src/entities/subscription.go b/app/go/src/entities/subscription.go index 4c2ebdb..bbb4dc5 100644 --- a/app/go/src/entities/subscription.go +++ b/app/go/src/entities/subscription.go @@ -1,15 +1,15 @@ package entities type SubscriptionItem struct { - PK string `dynamodbav:"pk"` - SK string `dynamodbav:"sk"` - Type string `dynamodbav:"type"` - PlanSKU string `dynamodbav:"planSku"` - StartDate string `dynamodbav:"startDate"` - ExpiresAt string `dynamodbav:"expiresAt"` - CancelledAt *string `dynamodbav:"cancelledAt"` - LastModifiedAt string `dynamodbav:"lastModifiedAt"` - Attributes map[string]interface{} `dynamodbav:"attributes"` + PK string `dynamodbav:"pk"` + SK string `dynamodbav:"sk"` + Type string `dynamodbav:"type"` + PlanSKU string `dynamodbav:"planSku"` + StartDate string `dynamodbav:"startDate"` + ExpiresAt string `dynamodbav:"expiresAt"` + CancelledAt *string `dynamodbav:"cancelledAt"` + LastModifiedAt string `dynamodbav:"lastModifiedAt"` + Attributes map[string]any `dynamodbav:"attributes"` } type Plan struct { From 0e72dd553617c59744af1e40459865ccdd7f6f35 Mon Sep 17 00:00:00 2001 From: DanielGA Date: Thu, 23 Oct 2025 13:56:28 -0600 Subject: [PATCH 10/10] Update interface to any for readability in sub response --- app/go/src/dtos/dtos.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/go/src/dtos/dtos.go b/app/go/src/dtos/dtos.go index 6620ee5..6e470fb 100644 --- a/app/go/src/dtos/dtos.go +++ b/app/go/src/dtos/dtos.go @@ -8,7 +8,7 @@ type SubscriptionResponse struct { ExpiresAt string `json:"expiresAt"` CancelledAt *string `json:"cancelledAt"` Status SubStatus `json:"status"` - Attributes map[string]interface{} `json:"attributes"` + Attributes map[string]any `json:"attributes"` } type SubscriptionResponsePlan struct {