diff --git a/internal/controllers/subscriptions.go b/internal/controllers/subscriptions.go index fdbc7c6..74ad69a 100644 --- a/internal/controllers/subscriptions.go +++ b/internal/controllers/subscriptions.go @@ -113,13 +113,8 @@ func (sa *SubscriptionAdder) AddSubscription(tx *gorm.DB, req model.Subscription return sa.subscriptionError(*username, err.Error()) } - // Define the subscription options. - opts := &model.SubscriptionOptions{ - Paid: paid, - } - // Add the subscription. - sub, err := db.SubscribeUserToPlan(sa.cfg.Ctx, tx, user, plan, opts) + sub, err := db.SubscribeUserToPlan(sa.cfg.Ctx, tx, user, plan, &req.SubscriptionOptions) if err != nil { log.Error(err) return sa.subscriptionError(*username, err.Error()) diff --git a/internal/controllers/users.go b/internal/controllers/users.go index 2bc2284..719f879 100644 --- a/internal/controllers/users.go +++ b/internal/controllers/users.go @@ -13,6 +13,7 @@ import ( "github.com/cyverse/QMS/internal/db" "github.com/cyverse/QMS/internal/httpmodel" "github.com/cyverse/QMS/internal/model" + "github.com/cyverse/QMS/internal/model/timestamp" "github.com/cyverse/QMS/internal/query" "github.com/labstack/echo/v4" "github.com/sirupsen/logrus" @@ -403,10 +404,11 @@ func (s Server) UpdateSubscription(ctx echo.Context) error { log.Debug("deactivated all active plans for the user") // Define the subscription options. + endTimestamp := timestamp.Timestamp(endDate) opts := &model.SubscriptionOptions{ Paid: &paid, Periods: &periods, - EndDate: &endDate, + EndDate: &endTimestamp, } // Subscribe the user to the plan. diff --git a/internal/model/subscriptions.go b/internal/model/subscriptions.go index f116cb8..8918eee 100644 --- a/internal/model/subscriptions.go +++ b/internal/model/subscriptions.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "time" + + "github.com/cyverse/QMS/internal/model/timestamp" +) // SubscriptionOptions represents options that can be applied to a new subscription. // @@ -13,7 +17,7 @@ type SubscriptionOptions struct { Periods *int32 `json:"periods"` // The effective end date of the subscription. - EndDate *time.Time `json:"end_date"` + EndDate *timestamp.Timestamp `json:"end_date"` } // Return the appropriate paid flag for the subscription options. @@ -39,7 +43,7 @@ func (o *SubscriptionOptions) GetEndDate(startDate time.Time) time.Time { if o.EndDate == nil { return startDate.AddDate(int(o.GetPeriods()), 0, 0) } else { - return *o.EndDate + return time.Time(*o.EndDate) } } @@ -47,6 +51,8 @@ func (o *SubscriptionOptions) GetEndDate(startDate time.Time) time.Time { // // swagger: model type SubscriptionRequest struct { + SubscriptionOptions + // The username to associate with the subscription // // required: true @@ -56,9 +62,6 @@ type SubscriptionRequest struct { // // required: true PlanName *string `json:"plan_name"` - - // True if the user paid for the subscription. - Paid *bool `json:"paid"` } // SubscriptionRequests represents a list of subscription requests. diff --git a/internal/model/timestamp/timestamp.go b/internal/model/timestamp/timestamp.go new file mode 100644 index 0000000..54fc50f --- /dev/null +++ b/internal/model/timestamp/timestamp.go @@ -0,0 +1,78 @@ +package timestamp + +import ( + "fmt" + "regexp" + "strconv" + "time" +) + +const ( + DateOnly = time.DateOnly + DateTimeLocal = "2006-01-02T15:04:05" + RFC3339 = time.RFC3339 +) + +var ( + DateOnlyRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + DateTimeLocalRegexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$`) + RFC3339Regexp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$`) +) + +// Timestamp is an alias for time.Time with lenient parsing in the local time zone by default. +type Timestamp time.Time + +// layoutForValue returns the layout to use for a given timestamp value. +func layoutForValue(value string) (string, error) { + switch { + case DateOnlyRegexp.MatchString(value): + return DateOnly, nil + case DateTimeLocalRegexp.MatchString(value): + return DateTimeLocal, nil + case RFC3339Regexp.MatchString(value): + return RFC3339, nil + default: + return "", fmt.Errorf("unrecognized timestamp layout: %s", value) + } +} + +// Parse attempts to parse the given value as a timestamp. The timestamp will be parsed in the time zone of the +// current location unless the time zone is included in the timestamp itself. The accepted formats are: +// +// 2024-02-21 - Midnight on the specified date in the local time zone. +// 2024-02-21T01:02:03 - The specified date and time in the local time zone. +// 2024-02-21T01:02:03Z - The specified date and time in UTC. +// 2024-02-01T01:02:03-07:00 - The specified date and time in the specified time zone. +func Parse(value string) (Timestamp, error) { + var t time.Time + + // Determine the timestamp layout. + layout, err := layoutForValue(value) + if err != nil { + return Timestamp(t), err + } + + // Parse the timestamp. + t, err = time.ParseInLocation(layout, value, time.Now().Location()) + return Timestamp(t), err +} + +// UnmarshalJSON converts a JSON to a timestamp. +func (t *Timestamp) UnmarshalJSON(data []byte) error { + value := string(data) + + // Ignore empty values. + if value == "null" || value == `""` { + return nil + } + + // Unquote the string. + value, err := strconv.Unquote(value) + if err != nil { + return err + } + + // Parse the timestamp. + *t, err = Parse(value) + return err +}