mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-28 16:06:37 +01:00
feat: improved duplicate (#927)
* feat: improved duplicate * feat: enhance item duplication process with transaction handling and error logging for attachments and fields * feat: add error logging during transaction rollback in item duplication process for better debugging * feat: don't try and rollback is the commit succeeded * feat: add customizable duplication options for items, including prefix and field copying settings in API and UI * fix: simplify duplication checks for custom fields, attachments, and maintenance entries in ItemsRepository duplication method * refactor: import DuplicateSettings type from composables and sort import issues
This commit is contained in:
@@ -254,6 +254,25 @@ func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
|
||||
return adapters.ActionID("id", fn, http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleItemDuplicate godocs
|
||||
//
|
||||
// @Summary Duplicate Item
|
||||
// @Tags Items
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param payload body repo.DuplicateOptions true "Duplicate Options"
|
||||
// @Success 201 {object} repo.ItemOut
|
||||
// @Router /v1/items/{id}/duplicate [POST]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemDuplicate() errchain.HandlerFunc {
|
||||
fn := func(r *http.Request, ID uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
|
||||
ctx := services.NewContext(r.Context())
|
||||
return ctrl.svc.Items.Duplicate(ctx, ctx.GID, ID, options)
|
||||
}
|
||||
|
||||
return adapters.ActionID("id", fn, http.StatusCreated)
|
||||
}
|
||||
|
||||
// HandleGetAllCustomFieldNames godocs
|
||||
//
|
||||
// @Summary Get All Custom Field Names
|
||||
|
||||
@@ -129,6 +129,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
|
||||
r.Put("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...))
|
||||
r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
|
||||
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
|
||||
r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...))
|
||||
|
||||
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
|
||||
r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
|
||||
|
||||
@@ -943,6 +943,48 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/duplicate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
],
|
||||
"summary": "Duplicate Item",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Item ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Duplicate Options",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.DuplicateOptions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.ItemOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/maintenance": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3129,6 +3171,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.DuplicateOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"copyAttachments": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyCustomFields": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyMaintenance": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -941,6 +941,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/duplicate": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Items"
|
||||
],
|
||||
"summary": "Duplicate Item",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Item ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Duplicate Options",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.DuplicateOptions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/repo.ItemOut"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/items/{id}/maintenance": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3127,6 +3169,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.DuplicateOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"copyAttachments": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyCustomFields": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyMaintenance": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -667,6 +667,17 @@ definitions:
|
||||
search_engine_name:
|
||||
type: string
|
||||
type: object
|
||||
repo.DuplicateOptions:
|
||||
properties:
|
||||
copyAttachments:
|
||||
type: boolean
|
||||
copyCustomFields:
|
||||
type: boolean
|
||||
copyMaintenance:
|
||||
type: boolean
|
||||
copyPrefix:
|
||||
type: string
|
||||
type: object
|
||||
repo.Group:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -1968,6 +1979,32 @@ paths:
|
||||
summary: Update Item Attachment
|
||||
tags:
|
||||
- Items Attachments
|
||||
/v1/items/{id}/duplicate:
|
||||
post:
|
||||
parameters:
|
||||
- description: Item ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Duplicate Options
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/repo.DuplicateOptions'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/repo.ItemOut'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Duplicate Item
|
||||
tags:
|
||||
- Items
|
||||
/v1/items/{id}/maintenance:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
@@ -337,6 +337,7 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s=
|
||||
github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||
|
||||
@@ -38,6 +38,10 @@ func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut,
|
||||
return svc.repo.Items.Create(ctx, ctx.GID, item)
|
||||
}
|
||||
|
||||
func (svc *ItemService) Duplicate(ctx Context, gid, id uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
|
||||
return svc.repo.Items.Duplicate(ctx, gid, id, options)
|
||||
}
|
||||
|
||||
func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) {
|
||||
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/itemfield"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/label"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/location"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/maintenanceentry"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
|
||||
)
|
||||
@@ -46,6 +48,13 @@ type (
|
||||
OrderBy string `json:"orderBy"`
|
||||
}
|
||||
|
||||
DuplicateOptions struct {
|
||||
CopyMaintenance bool `json:"copyMaintenance"`
|
||||
CopyAttachments bool `json:"copyAttachments"`
|
||||
CopyCustomFields bool `json:"copyCustomFields"`
|
||||
CopyPrefix string `json:"copyPrefix"`
|
||||
}
|
||||
|
||||
ItemField struct {
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
@@ -1004,3 +1013,164 @@ func (e *ItemsRepository) SetPrimaryPhotos(ctx context.Context, gid uuid.UUID) (
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Duplicate creates a copy of an item with configurable options for what data to copy.
|
||||
// The new item will have the next available asset ID and a customizable prefix in the name.
|
||||
func (e *ItemsRepository) Duplicate(ctx context.Context, gid, id uuid.UUID, options DuplicateOptions) (ItemOut, error) {
|
||||
tx, err := e.db.Tx(ctx)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to rollback transaction during item duplication")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Get the original item with all its data
|
||||
originalItem, err := e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
|
||||
nextAssetID, err := e.GetHighestAssetID(ctx, gid)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
nextAssetID++
|
||||
|
||||
// Set default copy prefix if not provided
|
||||
if options.CopyPrefix == "" {
|
||||
options.CopyPrefix = "Copy of "
|
||||
}
|
||||
|
||||
// Create the new item directly in the transaction
|
||||
newItemID := uuid.New()
|
||||
itemBuilder := tx.Item.Create().
|
||||
SetID(newItemID).
|
||||
SetName(options.CopyPrefix + originalItem.Name).
|
||||
SetDescription(originalItem.Description).
|
||||
SetQuantity(originalItem.Quantity).
|
||||
SetLocationID(originalItem.Location.ID).
|
||||
SetGroupID(gid).
|
||||
SetAssetID(int(nextAssetID)).
|
||||
SetSerialNumber(originalItem.SerialNumber).
|
||||
SetModelNumber(originalItem.ModelNumber).
|
||||
SetManufacturer(originalItem.Manufacturer).
|
||||
SetLifetimeWarranty(originalItem.LifetimeWarranty).
|
||||
SetWarrantyExpires(originalItem.WarrantyExpires.Time()).
|
||||
SetWarrantyDetails(originalItem.WarrantyDetails).
|
||||
SetPurchaseTime(originalItem.PurchaseTime.Time()).
|
||||
SetPurchaseFrom(originalItem.PurchaseFrom).
|
||||
SetPurchasePrice(originalItem.PurchasePrice).
|
||||
SetSoldTime(originalItem.SoldTime.Time()).
|
||||
SetSoldTo(originalItem.SoldTo).
|
||||
SetSoldPrice(originalItem.SoldPrice).
|
||||
SetSoldNotes(originalItem.SoldNotes).
|
||||
SetNotes(originalItem.Notes).
|
||||
SetInsured(originalItem.Insured).
|
||||
SetArchived(originalItem.Archived).
|
||||
SetSyncChildItemsLocations(originalItem.SyncChildItemsLocations)
|
||||
|
||||
if originalItem.Parent != nil {
|
||||
itemBuilder.SetParentID(originalItem.Parent.ID)
|
||||
}
|
||||
|
||||
// Add labels
|
||||
if len(originalItem.Labels) > 0 {
|
||||
labelIDs := make([]uuid.UUID, len(originalItem.Labels))
|
||||
for i, label := range originalItem.Labels {
|
||||
labelIDs[i] = label.ID
|
||||
}
|
||||
itemBuilder.AddLabelIDs(labelIDs...)
|
||||
}
|
||||
|
||||
_, err = itemBuilder.Save(ctx)
|
||||
if err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
|
||||
// Copy custom fields if requested
|
||||
if options.CopyCustomFields {
|
||||
for _, field := range originalItem.Fields {
|
||||
_, err = tx.ItemField.Create().
|
||||
SetItemID(newItemID).
|
||||
SetType(itemfield.Type(field.Type)).
|
||||
SetName(field.Name).
|
||||
SetTextValue(field.TextValue).
|
||||
SetNumberValue(field.NumberValue).
|
||||
SetBooleanValue(field.BooleanValue).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("field_name", field.Name).Msg("failed to copy custom field during duplication")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy attachments if requested
|
||||
if options.CopyAttachments {
|
||||
for _, att := range originalItem.Attachments {
|
||||
// Get the original attachment file
|
||||
originalAttachment, err := tx.Attachment.Query().
|
||||
Where(attachment.ID(att.ID)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
// Log error but continue to copy other attachments
|
||||
log.Warn().Err(err).Str("attachment_id", att.ID.String()).Msg("failed to find attachment during duplication")
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a copy of the attachment with the same file path
|
||||
// Since files are stored with hash-based paths, this is safe
|
||||
_, err = tx.Attachment.Create().
|
||||
SetItemID(newItemID).
|
||||
SetType(originalAttachment.Type).
|
||||
SetTitle(originalAttachment.Title).
|
||||
SetPath(originalAttachment.Path).
|
||||
SetMimeType(originalAttachment.MimeType).
|
||||
SetPrimary(originalAttachment.Primary).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("original_attachment_id", att.ID.String()).Msg("failed to copy attachment during duplication")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy maintenance entries if requested
|
||||
if options.CopyMaintenance {
|
||||
maintenanceEntries, err := tx.MaintenanceEntry.Query().
|
||||
Where(maintenanceentry.HasItemWith(item.ID(id))).
|
||||
All(ctx)
|
||||
if err == nil {
|
||||
for _, entry := range maintenanceEntries {
|
||||
_, err = tx.MaintenanceEntry.Create().
|
||||
SetItemID(newItemID).
|
||||
SetDate(entry.Date).
|
||||
SetScheduledDate(entry.ScheduledDate).
|
||||
SetName(entry.Name).
|
||||
SetDescription(entry.Description).
|
||||
SetCost(entry.Cost).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("maintenance_entry_id", entry.ID.String()).Msg("failed to copy maintenance entry during duplication")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ItemOut{}, err
|
||||
}
|
||||
committed = true
|
||||
|
||||
e.publishMutationEvent(gid)
|
||||
|
||||
// Get the final item with all copied data
|
||||
return e.GetOne(ctx, newItemID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user