From 607b06d2f26f9f13e0c0bd085c7b803177e613a9 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Wed, 15 Feb 2023 08:40:35 -0900 Subject: [PATCH] fix: date and datetime regression (#282) * use custom types.Date implementation * fix user registration bug * remove sanity check * fix datetime bug --- backend/app/api/handlers/v1/v1_ctrl_user.go | 3 +- backend/app/api/static/docs/docs.go | 1 + backend/app/api/static/docs/swagger.json | 1 + backend/app/api/static/docs/swagger.yaml | 1 + .../core/services/service_items_csv.go | 18 ++-- .../core/services/service_items_csv_test.go | 6 +- backend/internal/data/repo/repo_items.go | 51 +++++------ backend/internal/data/repo/repo_items_test.go | 7 +- backend/internal/data/types/date.go | 88 +++++++++++++++++++ frontend/lib/api/base/base-api.ts | 38 ++++++-- 10 files changed, 162 insertions(+), 52 deletions(-) create mode 100644 backend/internal/data/types/date.go diff --git a/backend/app/api/handlers/v1/v1_ctrl_user.go b/backend/app/api/handlers/v1/v1_ctrl_user.go index 565a723..98bc75e 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_user.go +++ b/backend/app/api/handlers/v1/v1_ctrl_user.go @@ -1,6 +1,7 @@ package v1 import ( + "fmt" "net/http" "github.com/google/uuid" @@ -28,7 +29,7 @@ func (ctrl *V1Controller) HandleUserRegistration() server.HandlerFunc { } if !ctrl.allowRegistration && regData.GroupToken == "" { - return validate.NewRequestError(nil, http.StatusForbidden) + return validate.NewRequestError(fmt.Errorf("user registration disabled"), http.StatusForbidden) } _, err := ctrl.svc.User.RegisterUser(r.Context(), regData) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 71c9840..3f7c37d 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -1983,6 +1983,7 @@ const docTemplate = `{ "type": "string" }, "warrantyExpires": { + "description": "Sold", "type": "string" } } diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 37d87b2..3c81456 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -1975,6 +1975,7 @@ "type": "string" }, "warrantyExpires": { + "description": "Sold", "type": "string" } } diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index d087658..4d7db79 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -275,6 +275,7 @@ definitions: warrantyDetails: type: string warrantyExpires: + description: Sold type: string type: object repo.LabelCreate: diff --git a/backend/internal/core/services/service_items_csv.go b/backend/internal/core/services/service_items_csv.go index 147da67..2d93f6e 100644 --- a/backend/internal/core/services/service_items_csv.go +++ b/backend/internal/core/services/service_items_csv.go @@ -7,9 +7,9 @@ import ( "io" "strconv" "strings" - "time" "github.com/hay-kot/homebox/backend/internal/data/repo" + "github.com/hay-kot/homebox/backend/internal/data/types" ) func determineSeparator(data []byte) (rune, error) { @@ -62,15 +62,6 @@ func parseFloat(s string) float64 { return f } -func parseDate(s string) time.Time { - if s == "" { - return time.Time{} - } - - p, _ := time.Parse("01/02/2006", s) - return p -} - func parseBool(s string) bool { switch strings.ToLower(s) { case "true", "yes", "1": @@ -92,6 +83,7 @@ type csvRow struct { } func newCsvRow(row []string) csvRow { + return csvRow{ Location: row[1], LabelStr: row[2], @@ -109,13 +101,13 @@ func newCsvRow(row []string) csvRow { Manufacturer: row[9], Notes: row[10], PurchaseFrom: row[11], - PurchaseTime: parseDate(row[13]), + PurchaseTime: types.DateFromString(row[13]), LifetimeWarranty: parseBool(row[14]), - WarrantyExpires: parseDate(row[15]), + WarrantyExpires: types.DateFromString(row[15]), WarrantyDetails: row[16], SoldTo: row[17], SoldPrice: parseFloat(row[18]), - SoldTime: parseDate(row[19]), + SoldTime: types.DateFromString(row[19]), SoldNotes: row[20], }, } diff --git a/backend/internal/core/services/service_items_csv_test.go b/backend/internal/core/services/service_items_csv_test.go index 5338979..af3056c 100644 --- a/backend/internal/core/services/service_items_csv_test.go +++ b/backend/internal/core/services/service_items_csv_test.go @@ -50,9 +50,9 @@ func Test_CorrectDateParsing(t *testing.T) { entity := newCsvRow(record) expected := expected[i-1] - assert.Equal(t, expected, entity.Item.PurchaseTime, fmt.Sprintf("Failed on row %d", i)) - assert.Equal(t, expected, entity.Item.WarrantyExpires, fmt.Sprintf("Failed on row %d", i)) - assert.Equal(t, expected, entity.Item.SoldTime, fmt.Sprintf("Failed on row %d", i)) + assert.Equal(t, expected, entity.Item.PurchaseTime.Time(), fmt.Sprintf("Failed on row %d", i)) + assert.Equal(t, expected, entity.Item.WarrantyExpires.Time(), fmt.Sprintf("Failed on row %d", i)) + assert.Equal(t, expected, entity.Item.SoldTime.Time(), fmt.Sprintf("Failed on row %d", i)) } } diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 0f644f0..4b67b97 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -13,6 +13,7 @@ import ( "github.com/hay-kot/homebox/backend/internal/data/ent/label" "github.com/hay-kot/homebox/backend/internal/data/ent/location" "github.com/hay-kot/homebox/backend/internal/data/ent/predicate" + "github.com/hay-kot/homebox/backend/internal/data/types" ) type ItemsRepository struct { @@ -78,20 +79,20 @@ type ( Manufacturer string `json:"manufacturer"` // Warranty - LifetimeWarranty bool `json:"lifetimeWarranty"` - WarrantyExpires time.Time `json:"warrantyExpires"` - WarrantyDetails string `json:"warrantyDetails"` + LifetimeWarranty bool `json:"lifetimeWarranty"` + WarrantyExpires types.Date `json:"warrantyExpires"` + WarrantyDetails string `json:"warrantyDetails"` // Purchase - PurchaseTime time.Time `json:"purchaseTime"` - PurchaseFrom string `json:"purchaseFrom"` - PurchasePrice float64 `json:"purchasePrice,string"` + PurchaseTime types.Date `json:"purchaseTime"` + PurchaseFrom string `json:"purchaseFrom"` + PurchasePrice float64 `json:"purchasePrice,string"` // Sold - SoldTime time.Time `json:"soldTime"` - SoldTo string `json:"soldTo"` - SoldPrice float64 `json:"soldPrice,string"` - SoldNotes string `json:"soldNotes"` + SoldTime types.Date `json:"soldTime"` + SoldTo string `json:"soldTo"` + SoldPrice float64 `json:"soldPrice,string"` + SoldNotes string `json:"soldNotes"` // Extras Notes string `json:"notes"` @@ -126,19 +127,19 @@ type ( Manufacturer string `json:"manufacturer"` // Warranty - LifetimeWarranty bool `json:"lifetimeWarranty"` - WarrantyExpires time.Time `json:"warrantyExpires"` - WarrantyDetails string `json:"warrantyDetails"` + LifetimeWarranty bool `json:"lifetimeWarranty"` + WarrantyExpires types.Date `json:"warrantyExpires"` + WarrantyDetails string `json:"warrantyDetails"` // Purchase - PurchaseTime time.Time `json:"purchaseTime"` - PurchaseFrom string `json:"purchaseFrom"` + PurchaseTime types.Date `json:"purchaseTime"` + PurchaseFrom string `json:"purchaseFrom"` // Sold - SoldTime time.Time `json:"soldTime"` - SoldTo string `json:"soldTo"` - SoldPrice float64 `json:"soldPrice,string"` - SoldNotes string `json:"soldNotes"` + SoldTime types.Date `json:"soldTime"` + SoldTo string `json:"soldTo"` + SoldPrice float64 `json:"soldPrice,string"` + SoldNotes string `json:"soldNotes"` // Extras Notes string `json:"notes"` @@ -232,7 +233,7 @@ func mapItemOut(item *ent.Item) ItemOut { AssetID: AssetID(item.AssetID), ItemSummary: mapItemSummary(item), LifetimeWarranty: item.LifetimeWarranty, - WarrantyExpires: item.WarrantyExpires, + WarrantyExpires: types.DateFromTime(item.WarrantyExpires), WarrantyDetails: item.WarrantyDetails, // Identification @@ -241,11 +242,11 @@ func mapItemOut(item *ent.Item) ItemOut { Manufacturer: item.Manufacturer, // Purchase - PurchaseTime: item.PurchaseTime, + PurchaseTime: types.DateFromTime(item.PurchaseTime), PurchaseFrom: item.PurchaseFrom, // Sold - SoldTime: item.SoldTime, + SoldTime: types.DateFromTime(item.SoldTime), SoldTo: item.SoldTo, SoldPrice: item.SoldPrice, SoldNotes: item.SoldNotes, @@ -526,17 +527,17 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, GID uuid.UUID, data SetModelNumber(data.ModelNumber). SetManufacturer(data.Manufacturer). SetArchived(data.Archived). - SetPurchaseTime(data.PurchaseTime). + SetPurchaseTime(data.PurchaseTime.Time()). SetPurchaseFrom(data.PurchaseFrom). SetPurchasePrice(data.PurchasePrice). - SetSoldTime(data.SoldTime). + SetSoldTime(data.SoldTime.Time()). SetSoldTo(data.SoldTo). SetSoldPrice(data.SoldPrice). SetSoldNotes(data.SoldNotes). SetNotes(data.Notes). SetLifetimeWarranty(data.LifetimeWarranty). SetInsured(data.Insured). - SetWarrantyExpires(data.WarrantyExpires). + SetWarrantyExpires(data.WarrantyExpires.Time()). SetWarrantyDetails(data.WarrantyDetails). SetQuantity(data.Quantity). SetAssetID(int(data.AssetID)) diff --git a/backend/internal/data/repo/repo_items_test.go b/backend/internal/data/repo/repo_items_test.go index 1f5ea0e..3236537 100644 --- a/backend/internal/data/repo/repo_items_test.go +++ b/backend/internal/data/repo/repo_items_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/google/uuid" + "github.com/hay-kot/homebox/backend/internal/data/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -237,15 +238,15 @@ func TestItemsRepository_Update(t *testing.T) { LabelIDs: nil, ModelNumber: fk.Str(10), Manufacturer: fk.Str(10), - PurchaseTime: time.Now(), + PurchaseTime: types.DateFromTime(time.Now()), PurchaseFrom: fk.Str(10), PurchasePrice: 300.99, - SoldTime: time.Now(), + SoldTime: types.DateFromTime(time.Now()), SoldTo: fk.Str(10), SoldPrice: 300.99, SoldNotes: fk.Str(10), Notes: fk.Str(10), - WarrantyExpires: time.Now(), + WarrantyExpires: types.DateFromTime(time.Now()), WarrantyDetails: fk.Str(10), LifetimeWarranty: true, } diff --git a/backend/internal/data/types/date.go b/backend/internal/data/types/date.go new file mode 100644 index 0000000..6aff82f --- /dev/null +++ b/backend/internal/data/types/date.go @@ -0,0 +1,88 @@ +package types + +import "time" + +// Date is a custom type that implements the MarshalJSON interface +// that applies date only formatting to the time.Time fields in order +// to avoid common time and timezone pitfalls when working with Times. +// +// Examples: +// +// "2019-01-01" -> time.Time{2019-01-01 00:00:00 +0000 UTC} +// "2019-01-01T21:10:30Z" -> time.Time{2019-01-01 00:00:00 +0000 UTC} +// "2019-01-01T21:10:30+01:00" -> time.Time{2019-01-01 00:00:00 +0000 UTC} +type Date time.Time + +func (d Date) Time() time.Time { + return time.Time(d) +} + +// DateFromTime returns a Date type from a time.Time type by stripping +// the time and timezone information. +func DateFromTime(t time.Time) Date { + dateOnlyTime := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + return Date(dateOnlyTime) +} + +// DateFromString returns a Date type from a string by parsing the +// string into a time.Time type and then stripping the time and +// timezone information. +// +// Errors are ignored and an empty Date is returned. +func DateFromString(s string) Date { + if s == "" { + return Date{} + } + + t, err := time.Parse("2006-01-02", s) + if err != nil { + // TODO: Remove - used by legacy importer + t, err = time.Parse("01/02/2006", s) + + if err != nil { + return Date{} + } + } + + return DateFromTime(t) +} + +func (d Date) String() string { + if time.Time(d).IsZero() { + return "" + } + + return time.Time(d).Format("2006-01-02") +} + +func (d Date) MarshalJSON() ([]byte, error) { + if time.Time(d).IsZero() { + return []byte(`""`), nil + } + + return []byte(`"` + d.String() + `"`), nil +} + +func (d *Date) UnmarshalJSON(data []byte) error { + str := string(data) + if str == `""` { + *d = Date{} + return nil + } + + // Try YYYY-MM-DD format + var t time.Time + t, err := time.Parse("2006-01-02", str) + if err != nil { + // Try default interface + err = t.UnmarshalJSON(data) + if err != nil { + return err + } + } + + // strip the time and timezone information + *d = DateFromTime(t) + + return nil +} diff --git a/frontend/lib/api/base/base-api.ts b/frontend/lib/api/base/base-api.ts index 184e4c7..cc6dd1e 100644 --- a/frontend/lib/api/base/base-api.ts +++ b/frontend/lib/api/base/base-api.ts @@ -16,9 +16,11 @@ export function hasKey(obj: object, key: string): obj is Required { export function parseDate(obj: T, keys: Array = []): T { const result = { ...obj }; [...keys, "createdAt", "updatedAt"].forEach(key => { - // @ts-ignore - TS doesn't know that we're checking for the key above + // @ts-expect-error - TS doesn't know that we're checking for the key above if (hasKey(result, key)) { - if (result[key] === ZERO_DATE) { + const value = result[key] as string; + + if (value === undefined || value === "" || value.startsWith(ZERO_DATE)) { const dt = new Date(); dt.setFullYear(1); @@ -26,11 +28,33 @@ export function parseDate(obj: T, keys: Array = []): T { return; } - // transform string to ensure dates are parsed as UTC dates instead of - // localized time stamps - const asStr = result[key] as string; - const cleaned = asStr.replaceAll("-", "/").split("T")[0]; - result[key] = new Date(cleaned); + // Possible Formats + // Date Only: YYYY-MM-DD + // Timestamp: 0001-01-01T00:00:00Z + + // Parse timestamps with default date + if (value.includes("T")) { + result[key] = new Date(value); + return; + } + + // Parse dates with default time + const split = value.split("-"); + + if (split.length !== 3) { + console.log(`Invalid date format: ${value}`); + throw new Error(`Invalid date format: ${value}`); + } + + const [year, month, day] = split; + + const dt = new Date(); + + dt.setFullYear(parseInt(year, 10)); + dt.setMonth(parseInt(month, 10) - 1); + dt.setDate(parseInt(day, 10)); + + result[key] = dt; } });