homebox/backend/internal/data/repo/repo_items.go
Hayden db80f8a159
chore: refactor api endpoints (#339)
* move typegen code

* update taskfile to fix code-gen caches and use 'dir' attribute

* enable dumping stack traces for errors

* log request start and stop

* set zerolog stack handler

* fix routes function

* refactor context adapters to use requests directly

* change some method signatures to support GID

* start requiring validation tags

* first pass on updating handlers to use adapters

* add errs package

* code gen

* tidy

* rework API to use external server package
2023-03-20 20:32:10 -08:00

789 lines
20 KiB
Go

package repo
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/ent/group"
"github.com/hay-kot/homebox/backend/internal/data/ent/item"
"github.com/hay-kot/homebox/backend/internal/data/ent/itemfield"
"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 {
db *ent.Client
}
type (
FieldQuery struct {
Name string
Value string
}
ItemQuery struct {
Page int
PageSize int
Search string `json:"search"`
AssetID AssetID `json:"assetId"`
LocationIDs []uuid.UUID `json:"locationIds"`
LabelIDs []uuid.UUID `json:"labelIds"`
SortBy string `json:"sortBy"`
IncludeArchived bool `json:"includeArchived"`
Fields []FieldQuery
}
ItemField struct {
ID uuid.UUID `json:"id,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
TextValue string `json:"textValue"`
NumberValue int `json:"numberValue"`
BooleanValue bool `json:"booleanValue"`
// TimeValue time.Time `json:"timeValue,omitempty"`
}
ItemCreate struct {
ImportRef string `json:"-"`
ParentID uuid.UUID `json:"parentId" extensions:"x-nullable"`
Name string `json:"name" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"required,min=1,max=1000"`
AssetID AssetID `json:"-"`
// Edges
LocationID uuid.UUID `json:"locationId"`
LabelIDs []uuid.UUID `json:"labelIds"`
}
ItemUpdate struct {
ParentID uuid.UUID `json:"parentId" extensions:"x-nullable,x-omitempty"`
ID uuid.UUID `json:"id"`
AssetID AssetID `json:"assetId"`
Name string `json:"name"`
Description string `json:"description"`
Quantity int `json:"quantity"`
Insured bool `json:"insured"`
Archived bool `json:"archived"`
// Edges
LocationID uuid.UUID `json:"locationId"`
LabelIDs []uuid.UUID `json:"labelIds"`
// Identifications
SerialNumber string `json:"serialNumber"`
ModelNumber string `json:"modelNumber"`
Manufacturer string `json:"manufacturer"`
// Warranty
LifetimeWarranty bool `json:"lifetimeWarranty"`
WarrantyExpires types.Date `json:"warrantyExpires"`
WarrantyDetails string `json:"warrantyDetails"`
// Purchase
PurchaseTime types.Date `json:"purchaseTime"`
PurchaseFrom string `json:"purchaseFrom"`
PurchasePrice float64 `json:"purchasePrice,string"`
// Sold
SoldTime types.Date `json:"soldTime"`
SoldTo string `json:"soldTo"`
SoldPrice float64 `json:"soldPrice,string"`
SoldNotes string `json:"soldNotes"`
// Extras
Notes string `json:"notes"`
Fields []ItemField `json:"fields"`
}
ItemPatch struct {
ID uuid.UUID `json:"id"`
Quantity *int `json:"quantity,omitempty" extensions:"x-nullable,x-omitempty"`
ImportRef *string `json:"importRef,omitempty" extensions:"x-nullable,x-omitempty"`
}
ItemSummary struct {
ImportRef string `json:"-"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Quantity int `json:"quantity"`
Insured bool `json:"insured"`
Archived bool `json:"archived"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
PurchasePrice float64 `json:"purchasePrice,string"`
// Edges
Location *LocationSummary `json:"location,omitempty" extensions:"x-nullable,x-omitempty"`
Labels []LabelSummary `json:"labels"`
}
ItemOut struct {
Parent *ItemSummary `json:"parent,omitempty" extensions:"x-nullable,x-omitempty"`
ItemSummary
AssetID AssetID `json:"assetId,string"`
SerialNumber string `json:"serialNumber"`
ModelNumber string `json:"modelNumber"`
Manufacturer string `json:"manufacturer"`
// Warranty
LifetimeWarranty bool `json:"lifetimeWarranty"`
WarrantyExpires types.Date `json:"warrantyExpires"`
WarrantyDetails string `json:"warrantyDetails"`
// Purchase
PurchaseTime types.Date `json:"purchaseTime"`
PurchaseFrom string `json:"purchaseFrom"`
// Sold
SoldTime types.Date `json:"soldTime"`
SoldTo string `json:"soldTo"`
SoldPrice float64 `json:"soldPrice,string"`
SoldNotes string `json:"soldNotes"`
// Extras
Notes string `json:"notes"`
Attachments []ItemAttachment `json:"attachments"`
Fields []ItemField `json:"fields"`
Children []ItemSummary `json:"children"`
}
)
var mapItemsSummaryErr = mapTEachErrFunc(mapItemSummary)
func mapItemSummary(item *ent.Item) ItemSummary {
var location *LocationSummary
if item.Edges.Location != nil {
loc := mapLocationSummary(item.Edges.Location)
location = &loc
}
labels := make([]LabelSummary, len(item.Edges.Label))
if item.Edges.Label != nil {
labels = mapEach(item.Edges.Label, mapLabelSummary)
}
return ItemSummary{
ID: item.ID,
Name: item.Name,
Description: item.Description,
ImportRef: item.ImportRef,
Quantity: item.Quantity,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
Archived: item.Archived,
PurchasePrice: item.PurchasePrice,
// Edges
Location: location,
Labels: labels,
// Warranty
Insured: item.Insured,
}
}
var (
mapItemOutErr = mapTErrFunc(mapItemOut)
mapItemsOutErr = mapTEachErrFunc(mapItemOut)
)
func mapFields(fields []*ent.ItemField) []ItemField {
result := make([]ItemField, len(fields))
for i, f := range fields {
result[i] = ItemField{
ID: f.ID,
Type: f.Type.String(),
Name: f.Name,
TextValue: f.TextValue,
NumberValue: f.NumberValue,
BooleanValue: f.BooleanValue,
// TimeValue: f.TimeValue,
}
}
return result
}
func mapItemOut(item *ent.Item) ItemOut {
var attachments []ItemAttachment
if item.Edges.Attachments != nil {
attachments = mapEach(item.Edges.Attachments, ToItemAttachment)
}
var fields []ItemField
if item.Edges.Fields != nil {
fields = mapFields(item.Edges.Fields)
}
var children []ItemSummary
if item.Edges.Children != nil {
children = mapEach(item.Edges.Children, mapItemSummary)
}
var parent *ItemSummary
if item.Edges.Parent != nil {
v := mapItemSummary(item.Edges.Parent)
parent = &v
}
return ItemOut{
Parent: parent,
AssetID: AssetID(item.AssetID),
ItemSummary: mapItemSummary(item),
LifetimeWarranty: item.LifetimeWarranty,
WarrantyExpires: types.DateFromTime(item.WarrantyExpires),
WarrantyDetails: item.WarrantyDetails,
// Identification
SerialNumber: item.SerialNumber,
ModelNumber: item.ModelNumber,
Manufacturer: item.Manufacturer,
// Purchase
PurchaseTime: types.DateFromTime(item.PurchaseTime),
PurchaseFrom: item.PurchaseFrom,
// Sold
SoldTime: types.DateFromTime(item.SoldTime),
SoldTo: item.SoldTo,
SoldPrice: item.SoldPrice,
SoldNotes: item.SoldNotes,
// Extras
Notes: item.Notes,
Attachments: attachments,
Fields: fields,
Children: children,
}
}
func (e *ItemsRepository) getOne(ctx context.Context, where ...predicate.Item) (ItemOut, error) {
q := e.db.Item.Query().Where(where...)
return mapItemOutErr(q.
WithFields().
WithLabel().
WithLocation().
WithGroup().
WithChildren().
WithParent().
WithAttachments(func(aq *ent.AttachmentQuery) {
aq.WithDocument()
}).
Only(ctx),
)
}
// GetOne returns a single item by ID. If the item does not exist, an error is returned.
// See also: GetOneByGroup to ensure that the item belongs to a specific group.
func (e *ItemsRepository) GetOne(ctx context.Context, id uuid.UUID) (ItemOut, error) {
return e.getOne(ctx, item.ID(id))
}
func (e *ItemsRepository) CheckRef(ctx context.Context, GID uuid.UUID, ref string) (bool, error) {
q := e.db.Item.Query().Where(item.HasGroupWith(group.ID(GID)))
return q.Where(item.ImportRef(ref)).Exist(ctx)
}
func (e *ItemsRepository) GetByRef(ctx context.Context, GID uuid.UUID, ref string) (ItemOut, error) {
return e.getOne(ctx, item.ImportRef(ref), item.HasGroupWith(group.ID(GID)))
}
// GetOneByGroup returns a single item by ID. If the item does not exist, an error is returned.
// GetOneByGroup ensures that the item belongs to a specific group.
func (e *ItemsRepository) GetOneByGroup(ctx context.Context, gid, id uuid.UUID) (ItemOut, error) {
return e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
}
// QueryByGroup returns a list of items that belong to a specific group based on the provided query.
func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q ItemQuery) (PaginationResult[ItemSummary], error) {
qb := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(gid)),
)
if q.IncludeArchived {
qb = qb.Where(
item.Or(
item.Archived(true),
item.Archived(false),
),
)
} else {
qb = qb.Where(item.Archived(false))
}
if q.Search != "" {
qb.Where(
item.Or(
item.NameContainsFold(q.Search),
item.DescriptionContainsFold(q.Search),
item.NotesContainsFold(q.Search),
),
)
}
if !q.AssetID.Nil() {
qb = qb.Where(item.AssetID(q.AssetID.Int()))
}
// Filters within this block define a AND relationship where each subset
// of filters is OR'd together.
//
// The goal is to allow matches like where the item has
// - one of the selected labels AND
// - one of the selected locations AND
// - one of the selected fields key/value matches
var andPredicates []predicate.Item
{
if len(q.LabelIDs) > 0 {
labelPredicates := make([]predicate.Item, 0, len(q.LabelIDs))
for _, l := range q.LabelIDs {
labelPredicates = append(labelPredicates, item.HasLabelWith(label.ID(l)))
}
andPredicates = append(andPredicates, item.Or(labelPredicates...))
}
if len(q.LocationIDs) > 0 {
locationPredicates := make([]predicate.Item, 0, len(q.LocationIDs))
for _, l := range q.LocationIDs {
locationPredicates = append(locationPredicates, item.HasLocationWith(location.ID(l)))
}
andPredicates = append(andPredicates, item.Or(locationPredicates...))
}
if len(q.Fields) > 0 {
fieldPredicates := make([]predicate.Item, 0, len(q.Fields))
for _, f := range q.Fields {
fieldPredicates = append(fieldPredicates, item.HasFieldsWith(
itemfield.And(
itemfield.Name(f.Name),
itemfield.TextValue(f.Value),
),
))
}
andPredicates = append(andPredicates, item.Or(fieldPredicates...))
}
}
if len(andPredicates) > 0 {
qb = qb.Where(item.And(andPredicates...))
}
count, err := qb.Count(ctx)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
qb = qb.Order(ent.Asc(item.FieldName)).
WithLabel().
WithLocation()
if q.Page != -1 || q.PageSize != -1 {
qb = qb.
Offset(calculateOffset(q.Page, q.PageSize)).
Limit(q.PageSize)
}
items, err := mapItemsSummaryErr(qb.All(ctx))
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
return PaginationResult[ItemSummary]{
Page: q.Page,
PageSize: q.PageSize,
Total: count,
Items: items,
}, nil
}
// QueryByAssetID returns items by asset ID. If the item does not exist, an error is returned.
func (e *ItemsRepository) QueryByAssetID(ctx context.Context, gid uuid.UUID, assetID AssetID, page int, pageSize int) (PaginationResult[ItemSummary], error) {
qb := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(gid)),
item.AssetID(int(assetID)),
)
if page != -1 || pageSize != -1 {
qb.Offset(calculateOffset(page, pageSize)).
Limit(pageSize)
} else {
page = -1
pageSize = -1
}
items, err := mapItemsSummaryErr(
qb.Order(ent.Asc(item.FieldName)).
WithLabel().
WithLocation().
All(ctx),
)
if err != nil {
return PaginationResult[ItemSummary]{}, err
}
return PaginationResult[ItemSummary]{
Page: page,
PageSize: pageSize,
Total: len(items),
Items: items,
}, nil
}
// GetAll returns all the items in the database with the Labels and Locations eager loaded.
func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]ItemOut, error) {
return mapItemsOutErr(e.db.Item.Query().
Where(item.HasGroupWith(group.ID(gid))).
WithLabel().
WithLocation().
WithFields().
All(ctx))
}
func (e *ItemsRepository) GetAllZeroAssetID(ctx context.Context, GID uuid.UUID) ([]ItemSummary, error) {
q := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(GID)),
item.AssetID(0),
).Order(
ent.Asc(item.FieldCreatedAt),
)
return mapItemsSummaryErr(q.All(ctx))
}
func (e *ItemsRepository) GetHighestAssetID(ctx context.Context, GID uuid.UUID) (AssetID, error) {
q := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(GID)),
).Order(
ent.Desc(item.FieldAssetID),
).Limit(1)
result, err := q.First(ctx)
if err != nil {
if ent.IsNotFound(err) {
return 0, nil
}
return 0, err
}
return AssetID(result.AssetID), nil
}
func (e *ItemsRepository) SetAssetID(ctx context.Context, GID uuid.UUID, ID uuid.UUID, assetID AssetID) error {
q := e.db.Item.Update().Where(
item.HasGroupWith(group.ID(GID)),
item.ID(ID),
)
_, err := q.SetAssetID(int(assetID)).Save(ctx)
return err
}
func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) {
q := e.db.Item.Create().
SetImportRef(data.ImportRef).
SetName(data.Name).
SetDescription(data.Description).
SetGroupID(gid).
SetLocationID(data.LocationID).
SetAssetID(int(data.AssetID))
if data.LabelIDs != nil && len(data.LabelIDs) > 0 {
q.AddLabelIDs(data.LabelIDs...)
}
result, err := q.Save(ctx)
if err != nil {
return ItemOut{}, err
}
return e.GetOne(ctx, result.ID)
}
func (e *ItemsRepository) Delete(ctx context.Context, id uuid.UUID) error {
return e.db.Item.DeleteOneID(id).Exec(ctx)
}
func (e *ItemsRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID) error {
_, err := e.db.Item.
Delete().
Where(
item.ID(id),
item.HasGroupWith(group.ID(gid)),
).Exec(ctx)
return err
}
func (e *ItemsRepository) UpdateByGroup(ctx context.Context, GID uuid.UUID, data ItemUpdate) (ItemOut, error) {
q := e.db.Item.Update().Where(item.ID(data.ID), item.HasGroupWith(group.ID(GID))).
SetName(data.Name).
SetDescription(data.Description).
SetLocationID(data.LocationID).
SetSerialNumber(data.SerialNumber).
SetModelNumber(data.ModelNumber).
SetManufacturer(data.Manufacturer).
SetArchived(data.Archived).
SetPurchaseTime(data.PurchaseTime.Time()).
SetPurchaseFrom(data.PurchaseFrom).
SetPurchasePrice(data.PurchasePrice).
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.Time()).
SetWarrantyDetails(data.WarrantyDetails).
SetQuantity(data.Quantity).
SetAssetID(int(data.AssetID))
currentLabels, err := e.db.Item.Query().Where(item.ID(data.ID)).QueryLabel().All(ctx)
if err != nil {
return ItemOut{}, err
}
set := newIDSet(currentLabels)
for _, l := range data.LabelIDs {
if set.Contains(l) {
set.Remove(l)
continue
}
q.AddLabelIDs(l)
}
if set.Len() > 0 {
q.RemoveLabelIDs(set.Slice()...)
}
if data.ParentID != uuid.Nil {
q.SetParentID(data.ParentID)
} else {
q.ClearParent()
}
err = q.Exec(ctx)
if err != nil {
return ItemOut{}, err
}
fields, err := e.db.ItemField.Query().Where(itemfield.HasItemWith(item.ID(data.ID))).All(ctx)
if err != nil {
return ItemOut{}, err
}
fieldIds := newIDSet(fields)
// Update Existing Fields
for _, f := range data.Fields {
if f.ID == uuid.Nil {
// Create New Field
_, err = e.db.ItemField.Create().
SetItemID(data.ID).
SetType(itemfield.Type(f.Type)).
SetName(f.Name).
SetTextValue(f.TextValue).
SetNumberValue(f.NumberValue).
SetBooleanValue(f.BooleanValue).
// SetTimeValue(f.TimeValue).
Save(ctx)
if err != nil {
return ItemOut{}, err
}
}
opt := e.db.ItemField.Update().
Where(
itemfield.ID(f.ID),
itemfield.HasItemWith(item.ID(data.ID)),
).
SetType(itemfield.Type(f.Type)).
SetName(f.Name).
SetTextValue(f.TextValue).
SetNumberValue(f.NumberValue).
SetBooleanValue(f.BooleanValue)
// SetTimeValue(f.TimeValue)
_, err = opt.Save(ctx)
if err != nil {
return ItemOut{}, err
}
fieldIds.Remove(f.ID)
continue
}
// Delete Fields that are no longer present
if fieldIds.Len() > 0 {
_, err = e.db.ItemField.Delete().
Where(
itemfield.IDIn(fieldIds.Slice()...),
itemfield.HasItemWith(item.ID(data.ID)),
).Exec(ctx)
if err != nil {
return ItemOut{}, err
}
}
return e.GetOne(ctx, data.ID)
}
func (e *ItemsRepository) GetAllZeroImportRef(ctx context.Context, GID uuid.UUID) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := e.db.Item.Query().
Where(
item.HasGroupWith(group.ID(GID)),
item.Or(
item.ImportRefEQ(""),
item.ImportRefIsNil(),
),
).
Select(item.FieldID).
Scan(ctx, &ids)
if err != nil {
return nil, err
}
return ids, nil
}
func (e *ItemsRepository) Patch(ctx context.Context, GID, ID uuid.UUID, data ItemPatch) error {
q := e.db.Item.Update().
Where(
item.ID(ID),
item.HasGroupWith(group.ID(GID)),
)
if data.ImportRef != nil {
q.SetImportRef(*data.ImportRef)
}
if data.Quantity != nil {
q.SetQuantity(*data.Quantity)
}
return q.Exec(ctx)
}
func (e *ItemsRepository) GetAllCustomFieldValues(ctx context.Context, GID uuid.UUID, name string) ([]string, error) {
type st struct {
Value string `json:"text_value"`
}
var values []st
err := e.db.Item.Query().
Where(
item.HasGroupWith(group.ID(GID)),
).
QueryFields().
Where(
itemfield.Name(name),
).
Unique(true).
Select(itemfield.FieldTextValue).
Scan(ctx, &values)
if err != nil {
return nil, fmt.Errorf("failed to get field values: %w", err)
}
valueStrings := make([]string, len(values))
for i, f := range values {
valueStrings[i] = f.Value
}
return valueStrings, nil
}
func (e *ItemsRepository) GetAllCustomFieldNames(ctx context.Context, GID uuid.UUID) ([]string, error) {
type st struct {
Name string `json:"name"`
}
var fields []st
err := e.db.Item.Query().
Where(
item.HasGroupWith(group.ID(GID)),
).
QueryFields().
Unique(true).
Select(itemfield.FieldName).
Scan(ctx, &fields)
if err != nil {
return nil, fmt.Errorf("failed to get custom fields: %w", err)
}
fieldNames := make([]string, len(fields))
for i, f := range fields {
fieldNames[i] = f.Name
}
return fieldNames, nil
}
// ZeroOutTimeFields is a helper function that can be invoked via the UI by a group member which will
// set all date fields to the beginning of the day.
//
// This is designed to resolve a long-time bug that has since been fixed with the time selector on the
// frontend. This function is intended to be used as a one-time fix for existing databases and may be
// removed in the future.
func (e *ItemsRepository) ZeroOutTimeFields(ctx context.Context, GID uuid.UUID) (int, error) {
q := e.db.Item.Query().Where(
item.HasGroupWith(group.ID(GID)),
item.Or(
item.PurchaseTimeNotNil(),
item.SoldTimeNotNil(),
item.WarrantyExpiresNotNil(),
),
)
items, err := q.All(ctx)
if err != nil {
return -1, fmt.Errorf("ZeroOutTimeFields() -> failed to get items: %w", err)
}
toDateOnly := func(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
updated := 0
for _, i := range items {
updateQ := e.db.Item.Update().Where(item.ID(i.ID))
if !i.PurchaseTime.IsZero() {
updateQ.SetPurchaseTime(toDateOnly(i.PurchaseTime))
}
if !i.SoldTime.IsZero() {
updateQ.SetSoldTime(toDateOnly(i.SoldTime))
}
if !i.WarrantyExpires.IsZero() {
updateQ.SetWarrantyExpires(toDateOnly(i.WarrantyExpires))
}
_, err = updateQ.Save(ctx)
if err != nil {
return updated, fmt.Errorf("ZeroOutTimeFields() -> failed to update item: %w", err)
}
updated++
}
return updated, nil
}