diff --git a/backend/app/api/handlers/v1/v1_ctrl_actions.go b/backend/app/api/handlers/v1/v1_ctrl_actions.go index ea490c0..9f89ea6 100644 --- a/backend/app/api/handlers/v1/v1_ctrl_actions.go +++ b/backend/app/api/handlers/v1/v1_ctrl_actions.go @@ -1,8 +1,10 @@ package v1 import ( + "context" "net/http" + "github.com/google/uuid" "github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/sys/validate" "github.com/hay-kot/homebox/backend/pkgs/server" @@ -13,6 +15,20 @@ type ActionAmountResult struct { Completed int `json:"completed"` } +func actionHandlerFactory(ref string, fn func(context.Context, uuid.UUID) (int, error)) server.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + ctx := services.NewContext(r.Context()) + + totalCompleted, err := fn(ctx, ctx.GID) + if err != nil { + log.Err(err).Str("action_ref", ref).Msg("failed to run action") + return validate.NewRequestError(err, http.StatusInternalServerError) + } + + return server.Respond(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted}) + } +} + // HandleGroupInvitationsCreate godoc // @Summary Ensures all items in the database have an asset id // @Tags Group @@ -21,17 +37,18 @@ type ActionAmountResult struct { // @Router /v1/actions/ensure-asset-ids [Post] // @Security Bearer func (ctrl *V1Controller) HandleEnsureAssetID() server.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) error { - ctx := services.NewContext(r.Context()) + return actionHandlerFactory("ensure asset IDs", ctrl.svc.Items.EnsureAssetID) +} - totalCompleted, err := ctrl.svc.Items.EnsureAssetID(ctx, ctx.GID) - if err != nil { - log.Err(err).Msg("failed to ensure asset id") - return validate.NewRequestError(err, http.StatusInternalServerError) - } - - return server.Respond(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted}) - } +// HandleEnsureImportRefs godoc +// @Summary Ensures all items in the database have an import ref +// @Tags Group +// @Produce json +// @Success 200 {object} ActionAmountResult +// @Router /v1/actions/ensure-import-refs [Post] +// @Security Bearer +func (ctrl *V1Controller) HandleEnsureImportRefs() server.HandlerFunc { + return actionHandlerFactory("ensure import refs", ctrl.svc.Items.EnsureImportRef) } // HandleItemDateZeroOut godoc @@ -42,15 +59,5 @@ func (ctrl *V1Controller) HandleEnsureAssetID() server.HandlerFunc { // @Router /v1/actions/zero-item-time-fields [Post] // @Security Bearer func (ctrl *V1Controller) HandleItemDateZeroOut() server.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) error { - ctx := services.NewContext(r.Context()) - - totalCompleted, err := ctrl.repo.Items.ZeroOutTimeFields(ctx, ctx.GID) - if err != nil { - log.Err(err).Msg("failed to ensure asset id") - return validate.NewRequestError(err, http.StatusInternalServerError) - } - - return server.Respond(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted}) - } + return actionHandlerFactory("zero out date time", ctrl.repo.Items.ZeroOutTimeFields) } diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 638a537..53083ee 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -89,6 +89,7 @@ func (a *app) mountRoutes(repos *repo.AllRepos) { a.server.Post(v1Base("/actions/ensure-asset-ids"), v1Ctrl.HandleEnsureAssetID(), userMW...) a.server.Post(v1Base("/actions/zero-item-time-fields"), v1Ctrl.HandleItemDateZeroOut(), userMW...) + a.server.Post(v1Base("/actions/ensure-import-refs"), v1Ctrl.HandleEnsureImportRefs(), userMW...) a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), userMW...) a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), userMW...) diff --git a/backend/app/api/static/docs/docs.go b/backend/app/api/static/docs/docs.go index 56802cc..77def9d 100644 --- a/backend/app/api/static/docs/docs.go +++ b/backend/app/api/static/docs/docs.go @@ -45,6 +45,30 @@ const docTemplate = `{ } } }, + "/v1/actions/ensure-import-refs": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Ensures all items in the database have an import ref", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.ActionAmountResult" + } + } + } + } + }, "/v1/actions/zero-item-time-fields": { "post": { "security": [ diff --git a/backend/app/api/static/docs/swagger.json b/backend/app/api/static/docs/swagger.json index 51a7f5b..08dbb4e 100644 --- a/backend/app/api/static/docs/swagger.json +++ b/backend/app/api/static/docs/swagger.json @@ -37,6 +37,30 @@ } } }, + "/v1/actions/ensure-import-refs": { + "post": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Group" + ], + "summary": "Ensures all items in the database have an import ref", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.ActionAmountResult" + } + } + } + } + }, "/v1/actions/zero-item-time-fields": { "post": { "security": [ diff --git a/backend/app/api/static/docs/swagger.yaml b/backend/app/api/static/docs/swagger.yaml index 23492f6..9c97791 100644 --- a/backend/app/api/static/docs/swagger.yaml +++ b/backend/app/api/static/docs/swagger.yaml @@ -650,6 +650,20 @@ paths: summary: Ensures all items in the database have an asset id tags: - Group + /v1/actions/ensure-import-refs: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.ActionAmountResult' + security: + - Bearer: [] + summary: Ensures all items in the database have an import ref + tags: + - Group /v1/actions/zero-item-time-fields: post: produces: diff --git a/backend/internal/core/services/reporting/io_row.go b/backend/internal/core/services/reporting/io_row.go index bccd877..faa5d25 100644 --- a/backend/internal/core/services/reporting/io_row.go +++ b/backend/internal/core/services/reporting/io_row.go @@ -13,9 +13,9 @@ type ExportItemFields struct { } type ExportTSVRow struct { + ImportRef string `csv:"HB.import_ref"` Location LocationString `csv:"HB.location"` LabelStr LabelString `csv:"HB.labels"` - ImportRef string `csv:"HB.import_ref"` AssetID repo.AssetID `csv:"HB.asset_id"` Archived bool `csv:"HB.archived"` diff --git a/backend/internal/core/services/reporting/io_sheet.go b/backend/internal/core/services/reporting/io_sheet.go index c0f6eaf..88d1d36 100644 --- a/backend/internal/core/services/reporting/io_sheet.go +++ b/backend/internal/core/services/reporting/io_sheet.go @@ -289,6 +289,8 @@ func (s *IOSheet) TSV() ([][]string, error) { v = val.Interface().(LocationString).String() case reflect.TypeOf(LabelString{}): v = val.Interface().(LabelString).String() + default: + log.Debug().Str("type", field.Type.String()).Msg("unknown type") } memcsv[rowIdx][col] = v diff --git a/backend/internal/core/services/service_items.go b/backend/internal/core/services/service_items.go index 9dd19ed..c1a37d5 100644 --- a/backend/internal/core/services/service_items.go +++ b/backend/internal/core/services/service_items.go @@ -64,6 +64,27 @@ func (svc *ItemService) EnsureAssetID(ctx context.Context, GID uuid.UUID) (int, return finished, nil } +func (svc *ItemService) EnsureImportRef(ctx context.Context, GID uuid.UUID) (int, error) { + ids, err := svc.repo.Items.GetAllZeroImportRef(ctx, GID) + if err != nil { + return 0, err + } + + finished := 0 + for _, itemID := range ids { + ref := uuid.New().String()[0:8] + + err = svc.repo.Items.Patch(ctx, GID, itemID, repo.ItemPatch{ImportRef: &ref}) + if err != nil { + return 0, err + } + + finished++ + } + + return finished, nil +} + func serializeLocation[T ~[]string](location T) string { return strings.Join(location, "/") } @@ -143,9 +164,10 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data io.Re for i := range sheet.Rows { row := sheet.Rows[i] + createRequired := true + // ======================================== // Preflight check for existing item - // TODO: Allow updates to existing items by matching on ImportRef if row.ImportRef != "" { exists, err := svc.repo.Items.CheckRef(ctx, GID, row.ImportRef) if err != nil { @@ -153,7 +175,7 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data io.Re } if exists { - continue + createRequired = false } } @@ -227,18 +249,31 @@ func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data io.Re // ======================================== // Create Item - newItem := repo.ItemCreate{ - ImportRef: row.ImportRef, - Name: row.Name, - Description: row.Description, - AssetID: effAID, - LocationID: locationID, - LabelIDs: labelIds, + var item repo.ItemOut + switch { + case createRequired: + newItem := repo.ItemCreate{ + ImportRef: row.ImportRef, + Name: row.Name, + Description: row.Description, + AssetID: effAID, + LocationID: locationID, + LabelIDs: labelIds, + } + + item, err = svc.repo.Items.Create(ctx, GID, newItem) + if err != nil { + return 0, err + } + default: + item, err = svc.repo.Items.GetByRef(ctx, GID, row.ImportRef) + if err != nil { + return 0, err + } } - item, err := svc.repo.Items.Create(ctx, GID, newItem) - if err != nil { - return 0, err + if item.ID == uuid.Nil { + panic("item ID is nil on import - this should never happen") } fields := make([]repo.ItemField, len(row.Fields)) diff --git a/backend/internal/data/ent/item_update.go b/backend/internal/data/ent/item_update.go index 88796e4..fa988df 100644 --- a/backend/internal/data/ent/item_update.go +++ b/backend/internal/data/ent/item_update.go @@ -67,6 +67,26 @@ func (iu *ItemUpdate) ClearDescription() *ItemUpdate { return iu } +// SetImportRef sets the "import_ref" field. +func (iu *ItemUpdate) SetImportRef(s string) *ItemUpdate { + iu.mutation.SetImportRef(s) + return iu +} + +// SetNillableImportRef sets the "import_ref" field if the given value is not nil. +func (iu *ItemUpdate) SetNillableImportRef(s *string) *ItemUpdate { + if s != nil { + iu.SetImportRef(*s) + } + return iu +} + +// ClearImportRef clears the value of the "import_ref" field. +func (iu *ItemUpdate) ClearImportRef() *ItemUpdate { + iu.mutation.ClearImportRef() + return iu +} + // SetNotes sets the "notes" field. func (iu *ItemUpdate) SetNotes(s string) *ItemUpdate { iu.mutation.SetNotes(s) @@ -713,6 +733,11 @@ func (iu *ItemUpdate) check() error { return &ValidationError{Name: "description", err: fmt.Errorf(`ent: validator failed for field "Item.description": %w`, err)} } } + if v, ok := iu.mutation.ImportRef(); ok { + if err := item.ImportRefValidator(v); err != nil { + return &ValidationError{Name: "import_ref", err: fmt.Errorf(`ent: validator failed for field "Item.import_ref": %w`, err)} + } + } if v, ok := iu.mutation.Notes(); ok { if err := item.NotesValidator(v); err != nil { return &ValidationError{Name: "notes", err: fmt.Errorf(`ent: validator failed for field "Item.notes": %w`, err)} @@ -773,6 +798,9 @@ func (iu *ItemUpdate) sqlSave(ctx context.Context) (n int, err error) { if iu.mutation.DescriptionCleared() { _spec.ClearField(item.FieldDescription, field.TypeString) } + if value, ok := iu.mutation.ImportRef(); ok { + _spec.SetField(item.FieldImportRef, field.TypeString, value) + } if iu.mutation.ImportRefCleared() { _spec.ClearField(item.FieldImportRef, field.TypeString) } @@ -1302,6 +1330,26 @@ func (iuo *ItemUpdateOne) ClearDescription() *ItemUpdateOne { return iuo } +// SetImportRef sets the "import_ref" field. +func (iuo *ItemUpdateOne) SetImportRef(s string) *ItemUpdateOne { + iuo.mutation.SetImportRef(s) + return iuo +} + +// SetNillableImportRef sets the "import_ref" field if the given value is not nil. +func (iuo *ItemUpdateOne) SetNillableImportRef(s *string) *ItemUpdateOne { + if s != nil { + iuo.SetImportRef(*s) + } + return iuo +} + +// ClearImportRef clears the value of the "import_ref" field. +func (iuo *ItemUpdateOne) ClearImportRef() *ItemUpdateOne { + iuo.mutation.ClearImportRef() + return iuo +} + // SetNotes sets the "notes" field. func (iuo *ItemUpdateOne) SetNotes(s string) *ItemUpdateOne { iuo.mutation.SetNotes(s) @@ -1961,6 +2009,11 @@ func (iuo *ItemUpdateOne) check() error { return &ValidationError{Name: "description", err: fmt.Errorf(`ent: validator failed for field "Item.description": %w`, err)} } } + if v, ok := iuo.mutation.ImportRef(); ok { + if err := item.ImportRefValidator(v); err != nil { + return &ValidationError{Name: "import_ref", err: fmt.Errorf(`ent: validator failed for field "Item.import_ref": %w`, err)} + } + } if v, ok := iuo.mutation.Notes(); ok { if err := item.NotesValidator(v); err != nil { return &ValidationError{Name: "notes", err: fmt.Errorf(`ent: validator failed for field "Item.notes": %w`, err)} @@ -2038,6 +2091,9 @@ func (iuo *ItemUpdateOne) sqlSave(ctx context.Context) (_node *Item, err error) if iuo.mutation.DescriptionCleared() { _spec.ClearField(item.FieldDescription, field.TypeString) } + if value, ok := iuo.mutation.ImportRef(); ok { + _spec.SetField(item.FieldImportRef, field.TypeString, value) + } if iuo.mutation.ImportRefCleared() { _spec.ClearField(item.FieldImportRef, field.TypeString) } diff --git a/backend/internal/data/ent/schema/item.go b/backend/internal/data/ent/schema/item.go index 5180f27..6efed21 100644 --- a/backend/internal/data/ent/schema/item.go +++ b/backend/internal/data/ent/schema/item.go @@ -38,8 +38,7 @@ func (Item) Fields() []ent.Field { return []ent.Field{ field.String("import_ref"). Optional(). - MaxLen(100). - Immutable(), + MaxLen(100), field.String("notes"). MaxLen(1000). Optional(), diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index 5e73565..69434b8 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -59,6 +59,7 @@ type ( 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"` @@ -99,6 +100,12 @@ type ( 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"` @@ -168,6 +175,7 @@ func mapItemSummary(item *ent.Item) ItemSummary { ID: item.ID, Name: item.Name, Description: item.Description, + ImportRef: item.ImportRef, Quantity: item.Quantity, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, @@ -285,6 +293,10 @@ func (e *ItemsRepository) CheckRef(ctx context.Context, GID uuid.UUID, ref strin 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) { @@ -628,6 +640,44 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, GID uuid.UUID, data 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"` diff --git a/frontend/components/App/ImportDialog.vue b/frontend/components/App/ImportDialog.vue index 4d225a2..adfe993 100644 --- a/frontend/components/App/ImportDialog.vue +++ b/frontend/components/App/ImportDialog.vue @@ -5,6 +5,27 @@ Import a CSV file containing your items, labels, and locations. See documentation for more information on the required format.
+