diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 4674dec..fe95cc6 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -21,6 +21,7 @@ import ( "time" gen "github.com/bit8bytes/gearberg/internal/api/gen" + "github.com/bit8bytes/gearberg/internal/equipment" "github.com/bit8bytes/gearberg/internal/httperr" "github.com/bit8bytes/gearberg/internal/pagination" "github.com/bit8bytes/gearberg/internal/templates/pages" @@ -47,7 +48,7 @@ func (app *application) ListEquipment(ctx context.Context, params gen.ListEquipm page := params.Page.Or(1) f := pagination.Filters{Page: page, PageSize: 25} - items, meta, err := app.services.equipment.GetFiltered(ctx, params.OrgID, params.Q.Or(""), "", f) + items, meta, err := app.services.equipment.List(ctx, equipment.ListParams{OrgID: params.OrgID, Query: params.Q.Or(""), Filters: f}) if err != nil { return nil, fmt.Errorf("failed to fetch equipment: %w", err) } diff --git a/cmd/web/handlers:equipment:categories.go b/cmd/web/handlers:categories.go similarity index 64% rename from cmd/web/handlers:equipment:categories.go rename to cmd/web/handlers:categories.go index 9773e2d..aa39be8 100644 --- a/cmd/web/handlers:equipment:categories.go +++ b/cmd/web/handlers:categories.go @@ -20,17 +20,16 @@ import ( "net/http" "net/url" - "github.com/bit8bytes/gearberg/internal/equipment/categories" + "github.com/bit8bytes/gearberg/internal/categories" "github.com/bit8bytes/gearberg/internal/httperr" "github.com/bit8bytes/gearberg/internal/templates/fragments" "github.com/bit8bytes/gearberg/internal/templates/pages" "github.com/bit8bytes/gearberg/pkg/htmx" - "github.com/segmentio/ksuid" ) type equipmentCategoriesData struct { OrgID string - Categories []categories.EquipmentCategory + Categories []categories.Category MaxCategories int SelectedID string SelectedName string @@ -47,11 +46,7 @@ func (app *application) getEquipmentCategories(w http.ResponseWriter, r *http.Re cats, err := app.services.equipmentcategories.List(ctx, id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve equipment categories.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) @@ -59,7 +54,7 @@ func (app *application) getEquipmentCategories(w http.ResponseWriter, r *http.Re data.Data = equipmentCategoriesData{ OrgID: id, Categories: cats, - MaxCategories: app.services.equipmentcategories.MaxCategories(), + MaxCategories: app.options.Limits.MaxOrgCategories, } return app.html.Render(w, r, http.StatusOK, pages.EquipmentCategoriesIndex, data) } @@ -78,10 +73,10 @@ func (app *application) postEquipmentCategoryNew(w http.ResponseWriter, r *http. form, err := categories.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *categories.Form) *httperr.Error { + fail := func(f *categories.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = equipmentCategoryData{OrgID: id} @@ -89,29 +84,24 @@ func (app *application) postEquipmentCategoryNew(w http.ResponseWriter, r *http. } if !form.Validate() { - return reRender(&form) + return fail(&form) } _, err = app.services.equipmentcategories.Create(ctx, categories.CreateEquipmentCategory{ - ID: ksuid.New().String(), OrgID: id, Name: form.Name, }) if err != nil { if errors.Is(err, categories.ErrConflict) { form.AddError("name", "A category with this name already exists.") - return reRender(&form) + return fail(&form) } if errors.Is(err, categories.ErrLimitExceeded) { - limit := app.services.equipmentcategories.MaxCategories() + limit := app.options.Limits.MaxOrgCategories form.AddError("name", fmt.Sprintf("Category limit reached. Only %d categories allowed per org.", limit)) - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to create equipment category.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(id)+"/settings/equipment-categories", http.StatusSeeOther) @@ -123,21 +113,20 @@ func (app *application) getEquipmentCategory(w http.ResponseWriter, r *http.Requ orgID := r.PathValue("org_id") catID := r.PathValue("id") - category, err := app.services.equipmentcategories.GetByID(ctx, catID) + category, err := app.services.equipmentcategories.Get(ctx, catID) if err != nil { if errors.Is(err, categories.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment category not found.", Code: http.StatusNotFound} - } - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve equipment category.", - Code: http.StatusInternalServerError, + return httperr.NotFound(err) } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) data.Form = &categories.Form{Name: category.Name} - data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} + data.Data = equipmentCategoryData{ + OrgID: orgID, + ID: catID, + } return app.html.Render(w, r, http.StatusOK, pages.EquipmentCategoriesDetail, data) } @@ -148,18 +137,21 @@ func (app *application) postEquipmentCategory(w http.ResponseWriter, r *http.Req form, err := categories.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *categories.Form) *httperr.Error { + fail := func(f *categories.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f - data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} + data.Data = equipmentCategoryData{ + OrgID: orgID, + ID: catID, + } return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentCategoriesDetail, data) } if !form.Validate() { - return reRender(&form) + return fail(&form) } _, err = app.services.equipmentcategories.Update(ctx, categories.UpdateEquipmentCategory{ @@ -169,13 +161,9 @@ func (app *application) postEquipmentCategory(w http.ResponseWriter, r *http.Req if err != nil { if errors.Is(err, categories.ErrConflict) { form.AddError("name", "A category with this name already exists.") - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to update equipment category.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings/equipment-categories/"+url.PathEscape(catID), http.StatusSeeOther) @@ -187,27 +175,26 @@ func (app *application) postDeleteEquipmentCategory(w http.ResponseWriter, r *ht orgID := r.PathValue("org_id") catID := r.PathValue("id") - if err := app.services.equipmentcategories.Delete(ctx, catID); err != nil { - if errors.Is(err, categories.ErrInUse) { - category, fetchErr := app.services.equipmentcategories.GetByID(ctx, catID) - if fetchErr != nil { - if errors.Is(fetchErr, categories.ErrNotFound) { - return &httperr.Error{Error: fetchErr, Message: "Equipment category not found.", Code: http.StatusNotFound} - } - return &httperr.Error{Error: fetchErr, Message: "Failed to retrieve equipment category.", Code: http.StatusInternalServerError} - } - f := &categories.Form{Name: category.Name} - f.AddError("delete", "Cannot delete: this category is assigned to one or more equipment items.") - data := app.html.TemplateData(r) - data.Form = f - data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} - return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentCategoriesDetail, data) + err := app.services.equipmentcategories.Delete(ctx, catID) + if err != nil { + if !errors.Is(err, categories.ErrInUse) { + return httperr.InternalServerError(err) } - return &httperr.Error{ - Error: err, - Message: "Failed to delete equipment category.", - Code: http.StatusInternalServerError, + + category, fetchErr := app.services.equipmentcategories.Get(ctx, catID) + if errors.Is(fetchErr, categories.ErrNotFound) { + return httperr.NotFound(err) } + if fetchErr != nil { + return httperr.InternalServerError(fetchErr) + } + + f := &categories.Form{Name: category.Name} + f.AddError("delete", "Cannot delete: this category is assigned to one or more equipment items.") + data := app.html.TemplateData(r) + data.Form = f + data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentCategoriesDetail, data) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/settings/equipment-categories", http.StatusSeeOther) @@ -228,14 +215,15 @@ func (app *application) getEquipmentCategoriesFragment(w http.ResponseWriter, r cats, err := app.services.equipmentcategories.List(ctx, orgID) if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("getEquipmentCategoriesFragment: %w", err), - Message: "Failed to retrieve categories.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("getEquipmentCategoriesFragment: %w", err)) } tmplData := app.html.TemplateData(r) - tmplData.Data = equipmentCategoriesData{OrgID: orgID, Categories: cats, SelectedID: selected, SelectedName: selectedName} + tmplData.Data = equipmentCategoriesData{ + OrgID: orgID, + Categories: cats, + SelectedID: selected, + SelectedName: selectedName, + } return app.html.RenderFragment(w, r, http.StatusOK, fragments.EquipmentCategories, tmplData) } diff --git a/cmd/web/handlers:equipment.go b/cmd/web/handlers:equipment.go index fa55189..539d88f 100644 --- a/cmd/web/handlers:equipment.go +++ b/cmd/web/handlers:equipment.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "html/template" + "math" "mime/multipart" "net/http" "net/url" @@ -27,8 +28,9 @@ import ( "time" "github.com/bit8bytes/gearberg/internal/barcodes" + "github.com/bit8bytes/gearberg/internal/categories" "github.com/bit8bytes/gearberg/internal/equipment" - "github.com/bit8bytes/gearberg/internal/equipment/categories" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" "github.com/bit8bytes/gearberg/internal/httperr" imgpkg "github.com/bit8bytes/gearberg/internal/image" "github.com/bit8bytes/gearberg/internal/money" @@ -57,21 +59,20 @@ func (app *application) getEquipmentPartOfFragment(w http.ResponseWriter, r *htt partOf, err := app.services.equipment.ListContainers(ctx, itemID) if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("getEquipmentPartOfFragment: %w", err), - Message: "Failed to load container memberships.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("getEquipmentPartOfFragment: %w", err)) } tmplData := app.html.TemplateData(r) - tmplData.Data = equipmentPartOfData{OrgID: orgID, PartOf: partOf} + tmplData.Data = equipmentPartOfData{ + OrgID: orgID, + PartOf: partOf, + } return app.html.RenderFragment(w, r, http.StatusOK, fragments.EquipmentPartOf, tmplData) } type equipmentData struct { OrgID string - Categories []categories.EquipmentCategory + Categories []categories.Category Inventories []equipment.Equipment Filtered bool Query string @@ -86,12 +87,12 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht ctx := r.Context() id, err := uid.Parse(r.PathValue("org_id")) if err != nil { - return &httperr.Error{Error: err, Message: "Invalid organization ID.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } cats, err := app.services.equipmentcategories.List(ctx, id) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve categories.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } qs := r.URL.Query() @@ -109,9 +110,14 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht PageSize: 25, } - items, meta, err := app.services.equipment.GetFiltered(ctx, id, query, category, f) + items, meta, err := app.services.equipment.List(ctx, equipment.ListParams{ + OrgID: id, + Query: query, + Category: category, + Filters: f, + }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveEquipmentURLs(items) @@ -136,7 +142,7 @@ func (app *application) getEquipmentSearchFragment(w http.ResponseWriter, r *htt ctx := r.Context() id, err := uid.Parse(r.PathValue("org_id")) if err != nil { - return &httperr.Error{Error: err, Message: "Invalid organization ID.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } qs := r.URL.Query() @@ -148,9 +154,14 @@ func (app *application) getEquipmentSearchFragment(w http.ResponseWriter, r *htt PageSize: 25, } - items, _, err := app.services.equipment.GetFiltered(ctx, id, query, category, f) + items, _, err := app.services.equipment.List(ctx, equipment.ListParams{ + OrgID: id, + Query: query, + Category: category, + Filters: f, + }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveEquipmentURLs(items) @@ -176,21 +187,22 @@ type equipmentItemData struct { func (app *application) getEquipmentNew(w http.ResponseWriter, r *http.Request) *httperr.Error { id := r.PathValue("org_id") data := app.html.TemplateData(r) - data.Form = equipment.NewNewForm() + data.Form = equipment.NewCreateForm() data.Data = equipmentItemData{OrgID: id} return app.html.Render(w, r, http.StatusOK, pages.EquipmentNew, data) } +//nolint:cyclop func (app *application) postEquipmentNew(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() id := r.PathValue("org_id") - form, err := equipment.ParseNew(r) + form, err := equipment.ParseForm(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *equipment.NewForm) *httperr.Error { + fail := func(f *equipment.NewForm) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = equipmentItemData{OrgID: id} @@ -198,42 +210,68 @@ func (app *application) postEquipmentNew(w http.ResponseWriter, r *http.Request) } if !form.Validate() { - return reRender(&form) + return fail(&form) + } + + catID := form.CategoryID + if catID == "" && form.CategoryName != "" { + catID, err = app.services.equipmentcategories.Upsert(ctx, id, form.CategoryName) + if err != nil { + return httperr.InternalServerError(err) + } + } + mfrID := form.ManufacturerID + if mfrID == "" && form.ManufacturerName != "" { + mfrID, err = app.services.manufacturers.Upsert(ctx, id, form.ManufacturerName) + if err != nil { + return httperr.InternalServerError(err) + } + } + locID := form.LocationID + if locID == "" && form.LocationName != "" { + locID, err = app.services.locations.Upsert(ctx, id, form.LocationName) + if err != nil { + return httperr.InternalServerError(err) + } } base := equipment.Base{ - OrgID: id, - EquipmentType: form.EquipmentType(), - UsageTypeID: equipment.ParseUsage(form.UsageTypeID).ID(), - Name: form.Name, - CategoryID: form.CategoryID, - CategoryName: form.CategoryName, - ManufacturerID: form.ManufacturerID, - ManufacturerName: form.ManufacturerName, - LocationID: form.LocationID, - LocationName: form.LocationName, - Notes: form.Notes, - Pricing: form.ToPricing(), + OrgID: id, + UsageTypeID: form.UsageType.ID(), + Name: form.Name, + CategoryID: catID, + ManufacturerID: mfrID, + LocationID: locID, + Notes: form.Notes, + Pricing: equipment.Pricing{ + PurchasePrice: form.PurchasePrice, + RentalPrice: form.RentalPrice, + }, } eq, err := app.services.equipment.Create(ctx, equipment.CreateEquipment{ - TrackingType: form.TrackingType(), - Base: base, - TotalStock: form.Count, - UnitCount: form.Count, + ItemType: form.ItemType, + Base: base, + TotalStock: form.Count, + UnitCount: form.Count, }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to create inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } if appErr := app.processEquipmentImage(r, id, eq.ID, form.Image, form.ImageHeader); appErr != nil { return appErr } - if eq.TrackingType == equipment.Bulk { + switch eq.TrackingType { + case tracking.Bulk: http.Redirect(w, r, "/orgs/"+url.PathEscape(id)+"/equipment", http.StatusSeeOther) - } else { - http.Redirect(w, r, "/orgs/"+url.PathEscape(id)+"/equipment/"+url.PathEscape(eq.ID)+"/units", http.StatusSeeOther) + case tracking.Serialized: + if eq.Type == equipment.KitType { + http.Redirect(w, r, "/orgs/"+url.PathEscape(id)+"/equipment/"+url.PathEscape(eq.ID)+"/content", http.StatusSeeOther) + } else { + http.Redirect(w, r, "/orgs/"+url.PathEscape(id)+"/equipment/"+url.PathEscape(eq.ID)+"/units", http.StatusSeeOther) + } } return nil } @@ -243,23 +281,29 @@ func (app *application) getEquipmentItem(w http.ResponseWriter, r *http.Request) orgID := r.PathValue("org_id") itemID := r.PathValue("id") - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) data := app.html.TemplateData(r) - f := equipment.DetailsFormFromEquipment(item) + f := item.DetailsForm() data.Form = &f - data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "details"} + data.Data = equipmentItemData{ + OrgID: orgID, + Item: item, + ID: itemID, + ActiveTab: "details", + } return app.html.Render(w, r, http.StatusOK, pages.EquipmentDetail, data) } +//nolint:cyclop func (app *application) postEquipmentItemDetails(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() orgID := r.PathValue("org_id") @@ -267,40 +311,67 @@ func (app *application) postEquipmentItemDetails(w http.ResponseWriter, r *http. form, err := equipment.ParseDetails(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } if appErr := app.processEquipmentImage(r, orgID, itemID, form.Image, form.ImageHeader); appErr != nil { return appErr } - if !form.Validate() { - item, err := app.services.equipment.GetByID(ctx, itemID) + fail := func(f *equipment.DetailsForm) *httperr.Error { + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) - data.Form = &form - data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "details"} + data.Form = f + data.Data = equipmentItemData{ + OrgID: orgID, + Item: item, + ID: itemID, + ActiveTab: "details", + } return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentDetail, data) } - itemType := equipment.TrackingTypeFromString(form.TypeID) + if !form.Validate() { + return fail(&form) + } + + catID := form.CategoryID + if catID == "" && form.CategoryName != "" { + catID, err = app.services.equipmentcategories.Upsert(ctx, orgID, form.CategoryName) + if err != nil { + return httperr.InternalServerError(err) + } + } + mfrID := form.ManufacturerID + if mfrID == "" && form.ManufacturerName != "" { + mfrID, err = app.services.manufacturers.Upsert(ctx, orgID, form.ManufacturerName) + if err != nil { + return httperr.InternalServerError(err) + } + } + locID := form.LocationID + if locID == "" && form.LocationName != "" { + locID, err = app.services.locations.Upsert(ctx, orgID, form.LocationName) + if err != nil { + return httperr.InternalServerError(err) + } + } + if err := app.services.equipment.UpdateDetails(ctx, equipment.UpdateEquipmentDetails{ - ID: itemID, - OrgID: orgID, - Type: itemType, - Name: form.Name, - CategoryID: form.CategoryID, - CategoryName: form.CategoryName, - ManufacturerID: form.ManufacturerID, - ManufacturerName: form.ManufacturerName, - LocationID: form.LocationID, - LocationName: form.LocationName, - Notes: form.Notes, - TotalStock: form.TotalStock, + ID: itemID, + OrgID: orgID, + Type: form.Type, + Name: form.Name, + CategoryID: catID, + ManufacturerID: mfrID, + LocationID: locID, + Notes: form.Notes, + TotalStock: form.TotalStock, }); err != nil { - return &httperr.Error{Error: err, Message: "Failed to update inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"#save", http.StatusSeeOther) @@ -314,20 +385,25 @@ func (app *application) postEquipmentItemProperties(w http.ResponseWriter, r *ht form, err := equipment.ParseProperties(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } if !form.Validate() { - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) data := app.html.TemplateData(r) data.Form = &form - data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "properties"} + data.Data = equipmentItemData{ + OrgID: orgID, + Item: item, + ID: itemID, + ActiveTab: "properties", + } return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentProperties, data) } @@ -335,7 +411,7 @@ func (app *application) postEquipmentItemProperties(w http.ResponseWriter, r *ht ID: itemID, Properties: form.ToProperties(), }); err != nil { - return &httperr.Error{Error: err, Message: "Failed to update inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/properties#save", http.StatusSeeOther) @@ -347,20 +423,25 @@ func (app *application) getEquipmentItemPricing(w http.ResponseWriter, r *http.R orgID := r.PathValue("org_id") itemID := r.PathValue("id") - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) data := app.html.TemplateData(r) - f := equipment.PricingFormFromEquipment(item) + f := item.PricingForm() data.Form = &f - data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "pricing"} + data.Data = equipmentItemData{ + OrgID: orgID, + Item: item, + ID: itemID, + ActiveTab: "pricing", + } return app.html.Render(w, r, http.StatusOK, pages.EquipmentPricing, data) } @@ -371,18 +452,23 @@ func (app *application) postEquipmentItemPricing(w http.ResponseWriter, r *http. form, err := equipment.ParsePricing(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } if !form.Validate() { - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) data.Form = &form - data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "pricing"} + data.Data = equipmentItemData{ + OrgID: orgID, + Item: item, + ID: itemID, + ActiveTab: "pricing", + } return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentPricing, data) } @@ -390,7 +476,7 @@ func (app *application) postEquipmentItemPricing(w http.ResponseWriter, r *http. ID: itemID, Pricing: form.ToPricing(), }); err != nil { - return &httperr.Error{Error: err, Message: "Failed to update inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/pricing#save", http.StatusSeeOther) @@ -402,20 +488,25 @@ func (app *application) getEquipmentItemProperties(w http.ResponseWriter, r *htt orgID := r.PathValue("org_id") itemID := r.PathValue("id") - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) data := app.html.TemplateData(r) - f := equipment.PropertiesFormFromEquipment(item) + f := item.PropertiesForm() data.Form = &f - data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "properties"} + data.Data = equipmentItemData{ + OrgID: orgID, + Item: item, + ID: itemID, + ActiveTab: "properties", + } return app.html.Render(w, r, http.StatusOK, pages.EquipmentProperties, data) } @@ -429,9 +520,13 @@ func (app *application) postEquipmentAddUnit(w http.ResponseWriter, r *http.Requ EquipmentID: itemID, }); err != nil { if errors.Is(err, equipment.ErrNotSerializedUnit) { - return &httperr.Error{Error: err, Message: "Units can only be added to serialized equipment.", Code: http.StatusBadRequest} + return &httperr.Error{ + Error: err, + Message: "Units can only be added to serialized equipment.", + Code: http.StatusBadRequest, + } } - return &httperr.Error{Error: err, Message: "Failed to add unit.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/units", http.StatusSeeOther) @@ -446,7 +541,7 @@ func (app *application) postEquipmentUpdateUnit(w http.ResponseWriter, r *http.R form, err := equipment.ParseUnit(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } if !form.Validate() { @@ -459,14 +554,14 @@ func (app *application) postEquipmentUpdateUnit(w http.ResponseWriter, r *http.R IsActive: form.StatusID, ManufacturerSerialNumber: form.ManufacturerSerialNumber, Remark: form.Remark, - PurchasePrice: form.ParsedPurchasePrice(), + PurchasePrice: form.PurchasePrice, PurchasedAt: form.PurchasedAt, NextInspectionAt: form.NextInspectionAt, }); err != nil { if errors.Is(err, equipment.ErrConflict) { return app.renderEquipmentUnits(w, r, orgID, itemID) } - return &httperr.Error{Error: err, Message: "Failed to update unit.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/units", http.StatusSeeOther) @@ -479,7 +574,7 @@ func (app *application) postEquipmentBulkUpdateInspection(w http.ResponseWriter, itemID := r.PathValue("id") if err := r.ParseForm(); err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } unitIDs := r.PostForm["unit_ids"] @@ -497,7 +592,7 @@ func (app *application) postEquipmentBulkUpdateInspection(w http.ResponseWriter, } if err := app.services.equipment.BulkUpdateNextInspection(ctx, unitIDs, nextInspectionAt); err != nil { - return &httperr.Error{Error: err, Message: "Failed to update units.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/units", http.StatusSeeOther) @@ -511,7 +606,7 @@ func (app *application) postDeleteEquipmentUnit(w http.ResponseWriter, r *http.R unitID := r.PathValue("unit_id") if err := app.services.equipment.DeleteUnit(ctx, unitID); err != nil { - return &httperr.Error{Error: err, Message: "Failed to delete unit.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/units", http.StatusSeeOther) @@ -536,20 +631,20 @@ func (app *application) getEquipmentUnits(w http.ResponseWriter, r *http.Request item, err := app.services.equipment.GetUnitsContainer(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } if errors.Is(err, equipment.ErrNoUnitsTab) { http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID), http.StatusSeeOther) return nil } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) units, err := app.services.equipment.ListUnits(ctx, itemID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve units.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) @@ -569,25 +664,25 @@ func (app *application) getEquipmentUnitQR(w http.ResponseWriter, r *http.Reques itemID := r.PathValue("id") unitID := r.PathValue("unit_id") - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } unit, err := app.services.equipment.GetUnit(ctx, unitID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Unit not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve unit.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } png, err := barcodes.QR(unit.SerialNumber) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to generate QR code.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } filename := unitQRFilename(item.Name, unit.SerialNumber) @@ -595,7 +690,7 @@ func (app *application) getEquipmentUnitQR(w http.ResponseWriter, r *http.Reques w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") w.Header().Set("Cache-Control", "no-store") if _, err := w.Write(png); err != nil { //nolint:gosec // png bytes are generated internally, not from user input - return &httperr.Error{Error: err, Message: "Failed to write response.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } return nil } @@ -605,25 +700,25 @@ func (app *application) getEquipmentUnitBarcode(w http.ResponseWriter, r *http.R itemID := r.PathValue("id") unitID := r.PathValue("unit_id") - item, err := app.services.equipment.GetByID(ctx, itemID) + item, err := app.services.equipment.Get(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } unit, err := app.services.equipment.GetUnit(ctx, unitID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Unit not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve unit.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } png, err := barcodes.Code128(unit.SerialNumber) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to generate barcode.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } filename := unitQRFilename(item.Name, unit.SerialNumber) @@ -631,7 +726,7 @@ func (app *application) getEquipmentUnitBarcode(w http.ResponseWriter, r *http.R w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") w.Header().Set("Cache-Control", "no-store") if _, err := w.Write(png); err != nil { //nolint:gosec // png bytes are generated internally, not from user input - return &httperr.Error{Error: err, Message: "Failed to write response.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } return nil } @@ -666,7 +761,11 @@ func (app *application) processEquipmentImage(r *http.Request, orgID, itemID str result, err := imgpkg.Process(file) if err != nil { - return &httperr.Error{Error: err, Message: "Invalid image file.", Code: http.StatusUnprocessableEntity} + return &httperr.Error{ + Error: err, + Message: "Invalid image file.", + Code: http.StatusUnprocessableEntity, + } } key := fmt.Sprintf("orgs/%s/equipment/%s", orgID, itemID) @@ -675,14 +774,14 @@ func (app *application) processEquipmentImage(r *http.Request, orgID, itemID str ContentType: result.ContentType, }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to store image.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } if err := app.services.equipment.SetImage(ctx, equipment.SetImage{ ID: itemID, StorageObjectID: &record.ID, }); err != nil { - return &httperr.Error{Error: err, Message: "Failed to link image.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } return nil } @@ -747,11 +846,7 @@ func (app *application) postDeleteEquipmentItem(w http.ResponseWriter, r *http.R Code: http.StatusConflict, } } - return &httperr.Error{ - Error: err, - Message: "Failed to delete inventory item.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther) @@ -775,28 +870,41 @@ func (app *application) getEquipmentContent(w http.ResponseWriter, r *http.Reque item, err := app.services.equipment.GetContentContainer(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } if errors.Is(err, equipment.ErrNoContentTab) { http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID), http.StatusSeeOther) return nil } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) content, err := app.services.equipment.ListContent(ctx, itemID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve content.", Code: http.StatusInternalServerError} - } - all, err := app.services.equipment.ListAll(ctx, orgID) + return httperr.InternalServerError(err) + } + all, _, err := app.services.equipment.List(ctx, equipment.ListParams{ + OrgID: orgID, + Filters: pagination.Filters{ + Page: 1, + PageSize: math.MaxInt32, + }, + }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve equipment list.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) data.Form = equipment.NewContentForm() - data.Data = equipmentContentData{OrgID: orgID, ID: itemID, Item: item, Content: content, AllEquipment: all, ActiveTab: "content"} + data.Data = equipmentContentData{ + OrgID: orgID, + ID: itemID, + Item: item, + Content: content, + AllEquipment: all, + ActiveTab: "content", + } return app.html.Render(w, r, http.StatusOK, pages.EquipmentContent, data) } @@ -805,29 +913,42 @@ func (app *application) renderEquipmentContentForm(w http.ResponseWriter, r *htt item, err := app.services.equipment.GetContentContainer(ctx, itemID) if err != nil { if errors.Is(err, equipment.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment item not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } if errors.Is(err, equipment.ErrNoContentTab) { http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID), http.StatusSeeOther) return nil } - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveItemURLs(item) content, err := app.services.equipment.ListContent(ctx, itemID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve content.", Code: http.StatusInternalServerError} - } - all, err := app.services.equipment.ListAll(ctx, orgID) + return httperr.InternalServerError(err) + } + all, _, err := app.services.equipment.List(ctx, equipment.ListParams{ + OrgID: orgID, + Filters: pagination.Filters{ + Page: 1, + PageSize: math.MaxInt32, + }, + }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve equipment list.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } if extraErr != "" { f.AddError("assign", extraErr) } data := app.html.TemplateData(r) data.Form = f - data.Data = equipmentContentData{OrgID: orgID, ID: itemID, Item: item, Content: content, AllEquipment: all, ActiveTab: "content"} + data.Data = equipmentContentData{ + OrgID: orgID, + ID: itemID, + Item: item, + Content: content, + AllEquipment: all, + ActiveTab: "content", + } return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentContent, data) } @@ -838,7 +959,7 @@ func (app *application) postEquipmentAssignContent(w http.ResponseWriter, r *htt form, err := equipment.ParseContent(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } if !form.Validate() { @@ -873,7 +994,7 @@ func (app *application) postEquipmentRemoveContent(w http.ResponseWriter, r *htt contentID := r.PathValue("content_id") if err := app.services.equipment.RemoveContent(ctx, contentID); err != nil { - return &httperr.Error{Error: err, Message: "Failed to remove content item.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/"+url.PathEscape(itemID)+"/content", http.StatusSeeOther) @@ -898,24 +1019,30 @@ func (app *application) getEquipmentPrint(w http.ResponseWriter, r *http.Request org, err := app.services.orgs.Get(ctx, orgID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve organization.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } qs := r.URL.Query() query := qs.Get("q") category := qs.Get("category") - showArchived := qs.Get("archived") == "true" - - filtered, err := app.services.equipment.ListAllFiltered(ctx, orgID, query, category, showArchived) + filtered, _, err := app.services.equipment.List(ctx, equipment.ListParams{ + OrgID: orgID, + Query: query, + Category: category, + Filters: pagination.Filters{ + Page: 1, + PageSize: math.MaxInt32, + }, + }) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve inventory.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } app.resolveEquipmentURLs(filtered) orgSettings, err := app.services.orgsettings.Get(ctx, orgID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve organization settings.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } var currency money.Currency diff --git a/cmd/web/handlers:equipment:export.go b/cmd/web/handlers:equipment:export.go index f0f75d3..b248ccf 100644 --- a/cmd/web/handlers:equipment:export.go +++ b/cmd/web/handlers:equipment:export.go @@ -16,11 +16,14 @@ package main import ( "encoding/csv" + "math" "net/http" "github.com/bit8bytes/gearberg/internal/equipment" - "github.com/bit8bytes/gearberg/internal/equipment/imports" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipmentimports" "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/pagination" ) // exportColumnCount must equal len(imports.ExpectedHeaders). This line fails to @@ -28,20 +31,20 @@ import ( // versa), catching column-count drift at build time. const exportColumnCount = 26 -var _ = [1]struct{}{}[exportColumnCount-len(imports.ExpectedHeaders)] +var _ = [1]struct{}{}[exportColumnCount-len(equipmentimports.ExpectedHeaders)] func (app *application) getEquipmentExport(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() orgID := r.PathValue("org_id") - items, err := app.services.equipment.ListAll(ctx, orgID) + items, _, err := app.services.equipment.List(ctx, equipment.ListParams{OrgID: orgID, Filters: pagination.Filters{Page: 1, PageSize: math.MaxInt32}}) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve equipment.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } mfrs, err := app.services.manufacturers.List(ctx, orgID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve manufacturers.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } mfrByID := make(map[string]string, len(mfrs)) for _, m := range mfrs { @@ -54,7 +57,7 @@ func (app *application) getEquipmentExport(w http.ResponseWriter, r *http.Reques _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) cw := csv.NewWriter(w) - _ = cw.Write(imports.ExpectedHeaders) + _ = cw.Write(equipmentimports.ExpectedHeaders) for _, item := range items { if item.IsArchived || item.TotalStock == 0 { @@ -62,14 +65,14 @@ func (app *application) getEquipmentExport(w http.ResponseWriter, r *http.Reques } var units []equipment.Unit - if item.TrackingType == equipment.Serialized { + if item.TrackingType == tracking.Serialized { units, err = app.services.equipment.ListUnits(ctx, item.ID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to retrieve units.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } } - for _, row := range imports.RowsForItem(item, mfrByID[item.ManufacturerID], units) { + for _, row := range equipmentimports.RowsForItem(item, mfrByID[item.ManufacturerID], units) { _ = cw.Write(row) } } diff --git a/cmd/web/handlers:equipment:import.go b/cmd/web/handlers:equipment:import.go index f6d9579..a1bcb38 100644 --- a/cmd/web/handlers:equipment:import.go +++ b/cmd/web/handlers:equipment:import.go @@ -20,7 +20,7 @@ import ( "net/url" "strings" - "github.com/bit8bytes/gearberg/internal/equipment/imports" + "github.com/bit8bytes/gearberg/internal/equipmentimports" "github.com/bit8bytes/gearberg/internal/httperr" "github.com/bit8bytes/gearberg/internal/templates/pages" ) @@ -53,7 +53,7 @@ type equipmentImportPreviewData struct { // groupImportRows collapses serialized staging rows that share a name into one // preview row and returns per-item counts for new and error items. -func groupImportRows(staged []imports.Row) (rows []importPreviewRow, cntNew, cntError int) { +func groupImportRows(staged []equipmentimports.Row) (rows []importPreviewRow, cntNew, cntError int) { type group struct { row importPreviewRow total int @@ -74,7 +74,7 @@ func groupImportRows(staged []imports.Row) (rows []importPreviewRow, cntNew, cnt ErrorMessage: r.ErrorMessage, } rows = append(rows, pr) - if r.Status == imports.StatusNew { + if r.Status == equipmentimports.StatusNew { cntNew++ } else { cntError++ @@ -89,15 +89,15 @@ func groupImportRows(staged []imports.Row) (rows []importPreviewRow, cntNew, cnt Name: r.Name, TypeLabel: r.TypeLabel, CategoryName: r.CategoryName, - Status: imports.StatusNew, + Status: equipmentimports.StatusNew, }} order = append(order, key) } g := seen[key] g.total++ - if r.Status == imports.StatusError && !g.hasErr { + if r.Status == equipmentimports.StatusError && !g.hasErr { g.hasErr = true - g.row.Status = imports.StatusError + g.row.Status = equipmentimports.StatusError g.row.ErrorMessage = r.ErrorMessage } } @@ -132,7 +132,7 @@ func (app *application) postEquipmentImport(w http.ResponseWriter, r *http.Reque ctx := r.Context() orgID := r.PathValue("org_id") - reRender := func(msg string) *httperr.Error { + fail := func(msg string) *httperr.Error { data := app.html.TemplateData(r) data.Data = equipmentImportData{OrgID: orgID, Error: msg} return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentImport, data) @@ -140,27 +140,23 @@ func (app *application) postEquipmentImport(w http.ResponseWriter, r *http.Reque const maxImportBytes = 32 << 20 // 32 MiB if err := r.ParseMultipartForm(maxImportBytes); err != nil { //nolint:gosec // maxImportBytes is a bounded constant (32 MiB) - return reRender("Could not parse form.") + return fail("Could not parse form.") } f, _, err := r.FormFile("file") if err != nil { - return reRender("No file uploaded.") + return fail("No file uploaded.") } defer func() { _ = f.Close() }() - rawRows, parseErr := imports.ParseCSV(f) + rawRows, parseErr := equipmentimports.ParseCSV(f) if parseErr != nil { - return reRender(parseErr.Error()) + return fail(parseErr.Error()) } importID, err := app.services.equipmentImports.Stage(ctx, orgID, rawRows) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to stage import.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/import?id="+url.QueryEscape(importID), http.StatusSeeOther) @@ -172,7 +168,7 @@ func (app *application) renderImportPreview(w http.ResponseWriter, r *http.Reque staged, err := app.services.equipmentImports.ListStaged(ctx, importID) if err != nil { - return &httperr.Error{Error: err, Message: "Failed to load import preview.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } previewRows, cntNew, cntError := groupImportRows(staged) @@ -193,12 +189,12 @@ func (app *application) postEquipmentImportConfirm(w http.ResponseWriter, r *htt orgID := r.PathValue("org_id") if err := r.ParseForm(); err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } importID := r.FormValue("import_id") if err := app.services.equipmentImports.Commit(ctx, importID, orgID); err != nil { - return &httperr.Error{Error: err, Message: "Failed to commit import.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther) @@ -209,6 +205,6 @@ func (app *application) postEquipmentImportConfirm(w http.ResponseWriter, r *htt func (app *application) getEquipmentImportTemplate(w http.ResponseWriter, _ *http.Request) *httperr.Error { w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="gearberg-import-template.csv"`) - _, _ = w.Write(imports.TemplateCSV) + _, _ = w.Write(equipmentimports.TemplateCSV) return nil } diff --git a/cmd/web/handlers:equipment:import_test.go b/cmd/web/handlers:equipment:import_test.go index 547853b..c80d797 100644 --- a/cmd/web/handlers:equipment:import_test.go +++ b/cmd/web/handlers:equipment:import_test.go @@ -20,7 +20,7 @@ import ( "strings" "testing" - "github.com/bit8bytes/gearberg/internal/equipment/imports" + "github.com/bit8bytes/gearberg/internal/equipmentimports" ) func TestEquipmentImportExportRoundTrip(t *testing.T) { @@ -34,7 +34,7 @@ func TestEquipmentImportExportRoundTrip(t *testing.T) { func roundTripUpload(t *testing.T) string { t.Helper() - code, header, _ := ts.postFile(t, "/orgs/"+alice.orgID+"/equipment/import", "file", "template.csv", imports.TemplateCSV) + code, header, _ := ts.postFile(t, "/orgs/"+alice.orgID+"/equipment/import", "file", "template.csv", equipmentimports.TemplateCSV) if code != http.StatusSeeOther { t.Fatalf("upload: want 303, got %d", code) } @@ -66,20 +66,20 @@ func assertRoundTrip(t *testing.T, body []byte) { assertSonySerials(t, rows) } -func parseExportCSV(t *testing.T, body []byte) []imports.RawRow { +func parseExportCSV(t *testing.T, body []byte) []equipmentimports.RawRow { t.Helper() // Strip the UTF-8 BOM the handler prepends so ParseCSV sees clean bytes. if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF { body = body[3:] } - rows, err := imports.ParseCSV(strings.NewReader(string(body))) + rows, err := equipmentimports.ParseCSV(strings.NewReader(string(body))) if err != nil { t.Fatalf("ParseCSV on export: %v", err) } return rows } -func assertExportCounts(t *testing.T, rows []imports.RawRow) { +func assertExportCounts(t *testing.T, rows []equipmentimports.RawRow) { t.Helper() counts := make(map[string]int) for _, r := range rows { @@ -96,7 +96,7 @@ func assertExportCounts(t *testing.T, rows []imports.RawRow) { } } -func assertShureQuantity(t *testing.T, rows []imports.RawRow) { +func assertShureQuantity(t *testing.T, rows []equipmentimports.RawRow) { t.Helper() for _, r := range rows { if r.Name == "Shure SM58" && r.Quantity != "7" { @@ -105,7 +105,7 @@ func assertShureQuantity(t *testing.T, rows []imports.RawRow) { } } -func assertSonySerials(t *testing.T, rows []imports.RawRow) { +func assertSonySerials(t *testing.T, rows []equipmentimports.RawRow) { t.Helper() serials := make(map[string]bool) for _, r := range rows { diff --git a/cmd/web/handlers:equipment:locations.go b/cmd/web/handlers:locations.go similarity index 71% rename from cmd/web/handlers:equipment:locations.go rename to cmd/web/handlers:locations.go index a0c74bf..93ed665 100644 --- a/cmd/web/handlers:equipment:locations.go +++ b/cmd/web/handlers:locations.go @@ -20,12 +20,11 @@ import ( "net/http" "net/url" - "github.com/bit8bytes/gearberg/internal/equipment/locations" "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/locations" "github.com/bit8bytes/gearberg/internal/templates/fragments" "github.com/bit8bytes/gearberg/internal/templates/pages" "github.com/bit8bytes/gearberg/pkg/htmx" - "github.com/segmentio/ksuid" ) type locationsData struct { @@ -45,13 +44,9 @@ func (app *application) getLocations(w http.ResponseWriter, r *http.Request) *ht ctx := r.Context() id := r.PathValue("org_id") - locs, err := app.services.locations.GetByOrgID(ctx, id) + locs, err := app.services.locations.List(ctx, id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve locations.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) @@ -59,7 +54,7 @@ func (app *application) getLocations(w http.ResponseWriter, r *http.Request) *ht data.Data = locationsData{ OrgID: id, Locations: locs, - MaxLocations: app.services.locations.MaxLocations(), + MaxLocations: app.options.Limits.MaxOrgLocations, } return app.html.Render(w, r, http.StatusOK, pages.LocationsIndex, data) } @@ -78,10 +73,10 @@ func (app *application) postLocationNew(w http.ResponseWriter, r *http.Request) form, err := locations.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *locations.Form) *httperr.Error { + fail := func(f *locations.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = locationData{OrgID: id} @@ -89,29 +84,24 @@ func (app *application) postLocationNew(w http.ResponseWriter, r *http.Request) } if !form.Validate() { - return reRender(&form) + return fail(&form) } _, err = app.services.locations.Create(ctx, locations.CreateLocation{ - ID: ksuid.New().String(), OrgID: id, Name: form.Name, }) if err != nil { if errors.Is(err, locations.ErrConflict) { form.AddError("name", "A location with this name already exists.") - return reRender(&form) + return fail(&form) } if errors.Is(err, locations.ErrLimitExceeded) { - limit := app.services.locations.MaxLocations() + limit := app.options.Limits.MaxOrgLocations form.AddError("name", fmt.Sprintf("Location limit reached. Only %d locations allowed per org.", limit)) - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to create location.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } dest := "/orgs/" + url.PathEscape(id) + "/settings/locations" @@ -127,18 +117,17 @@ func (app *application) getLocation(w http.ResponseWriter, r *http.Request) *htt loc, err := app.services.locations.GetByID(ctx, locID) if err != nil { if errors.Is(err, locations.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Location not found.", Code: http.StatusNotFound} - } - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve location.", - Code: http.StatusInternalServerError, + return httperr.NotFound(err) } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) data.Form = &locations.Form{Name: loc.Name} - data.Data = locationData{OrgID: orgID, ID: locID} + data.Data = locationData{ + OrgID: orgID, + ID: locID, + } return app.html.Render(w, r, http.StatusOK, pages.LocationsDetail, data) } @@ -148,26 +137,29 @@ func (app *application) postLocation(w http.ResponseWriter, r *http.Request) *ht form, err := locations.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } loc, err := app.services.locations.GetByID(ctx, locID) if err != nil { if errors.Is(err, locations.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Location not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve location.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } - reRender := func(f *locations.Form) *httperr.Error { + fail := func(f *locations.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f - data.Data = locationData{OrgID: loc.OrgID, ID: locID} + data.Data = locationData{ + OrgID: loc.OrgID, + ID: locID, + } return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.LocationsDetail, data) } if !form.Validate() { - return reRender(&form) + return fail(&form) } _, err = app.services.locations.Update(ctx, locations.UpdateLocation{ @@ -177,13 +169,9 @@ func (app *application) postLocation(w http.ResponseWriter, r *http.Request) *ht if err != nil { if errors.Is(err, locations.ErrConflict) { form.AddError("name", "A location with this name already exists.") - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to update location.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } dest := "/orgs/" + loc.OrgID + "/settings/locations/" + loc.ID @@ -197,11 +185,7 @@ func (app *application) postDeleteLocation(w http.ResponseWriter, r *http.Reques locID := r.PathValue("id") if err := app.services.locations.Delete(ctx, locID); err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to delete location.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } dest := "/orgs/" + url.PathEscape(orgID) + "/settings/locations" @@ -221,16 +205,17 @@ func (app *application) getEquipmentLocationsFragment(w http.ResponseWriter, r * selected := r.URL.Query().Get("selected") selectedName := r.URL.Query().Get("selected_name") - locs, err := app.services.locations.GetByOrgID(ctx, orgID) + locs, err := app.services.locations.List(ctx, orgID) if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("getEquipmentLocationsFragment: %w", err), - Message: "Failed to retrieve locations.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("getEquipmentLocationsFragment: %w", err)) } tmplData := app.html.TemplateData(r) - tmplData.Data = locationsData{OrgID: orgID, Locations: locs, SelectedID: selected, SelectedName: selectedName} + tmplData.Data = locationsData{ + OrgID: orgID, + Locations: locs, + SelectedID: selected, + SelectedName: selectedName, + } return app.html.RenderFragment(w, r, http.StatusOK, fragments.WarehouseLocations, tmplData) } diff --git a/cmd/web/handlers:login.go b/cmd/web/handlers:login.go index a7daf7f..9ffb5d9 100644 --- a/cmd/web/handlers:login.go +++ b/cmd/web/handlers:login.go @@ -27,7 +27,6 @@ import ( "github.com/bit8bytes/gearberg/internal/templates/pages" "github.com/bit8bytes/gearberg/pkg/htmx" pkgtokens "github.com/bit8bytes/gearberg/pkg/tokens" - "github.com/segmentio/ksuid" ) // redirectAfterLogin sends the user to their single org's equipment page if they @@ -51,7 +50,7 @@ func (app *application) getSignIn(w http.ResponseWriter, r *http.Request) *httpe func (app *application) postSignIn(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() - reRender := func(formWithErrors *accounts.SignInForm) *httperr.Error { + fail := func(formWithErrors *accounts.SignInForm) *httperr.Error { data := app.html.TemplateData(r) data.Form = formWithErrors data.Data = app.loginData() @@ -60,11 +59,11 @@ func (app *application) postSignIn(w http.ResponseWriter, r *http.Request) *http form, err := accounts.ParseSignInForm(r) if err != nil { - return reRender(&form) + return fail(&form) } if !form.Validate() { - return reRender(&form) + return fail(&form) } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) @@ -76,7 +75,7 @@ func (app *application) postSignIn(w http.ResponseWriter, r *http.Request) *http }) if err != nil { form.AddError("email", "Invalid email or password.") - return reRender(&form) + return fail(&form) } sessionSetAccountID(r.Context(), app.session, accountID) @@ -91,7 +90,7 @@ func (app *application) getSignUp(w http.ResponseWriter, r *http.Request) *httpe } func (app *application) postSignUp(w http.ResponseWriter, r *http.Request) *httperr.Error { - reRender := func(formWithErrors *accounts.SignUpForm) *httperr.Error { + fail := func(formWithErrors *accounts.SignUpForm) *httperr.Error { data := app.html.TemplateData(r) data.Form = formWithErrors return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.SignUp, data) @@ -99,11 +98,11 @@ func (app *application) postSignUp(w http.ResponseWriter, r *http.Request) *http form, err := accounts.ParseSignUpForm(r) if err != nil { - return reRender(&form) + return fail(&form) } if !form.Validate() { - return reRender(&form) + return fail(&form) } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) @@ -122,12 +121,12 @@ func (app *application) postSignUp(w http.ResponseWriter, r *http.Request) *http app.logger.ErrorContext(ctx, "sign up failed", "error", err) form.AddError("email", "Invalid email or password.") } - return reRender(&form) + return fail(&form) } sessionSetAccountID(r.Context(), app.session, accountID) - _, _ = app.services.orgsettings.Create(ctx, ksuid.New().String(), orgID) + _, _ = app.services.orgsettings.Create(ctx, orgID) http.Redirect(w, r, "/orgs/"+orgID+"/equipment", http.StatusSeeOther) return nil @@ -140,7 +139,7 @@ func (app *application) getForgotPassword(w http.ResponseWriter, r *http.Request } func (app *application) postForgotPassword(w http.ResponseWriter, r *http.Request) *httperr.Error { - reRender := func(formWithErrors *accounts.ForgotPasswordForm) *httperr.Error { + fail := func(formWithErrors *accounts.ForgotPasswordForm) *httperr.Error { data := app.html.TemplateData(r) data.Form = formWithErrors return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ForgotPassword, data) @@ -148,11 +147,11 @@ func (app *application) postForgotPassword(w http.ResponseWriter, r *http.Reques form, err := accounts.ParseForgotPasswordForm(r) if err != nil { - return reRender(&form) + return fail(&form) } if !form.Validate() { - return reRender(&form) + return fail(&form) } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) @@ -192,7 +191,7 @@ func (app *application) getResetPassword(w http.ResponseWriter, r *http.Request) } func (app *application) postResetPassword(w http.ResponseWriter, r *http.Request) *httperr.Error { - reRender := func(formWithErrors *accounts.ResetPasswordForm) *httperr.Error { + fail := func(formWithErrors *accounts.ResetPasswordForm) *httperr.Error { data := app.html.TemplateData(r) data.Form = formWithErrors return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ResetPassword, data) @@ -200,11 +199,11 @@ func (app *application) postResetPassword(w http.ResponseWriter, r *http.Request form, err := accounts.ParseResetPasswordForm(r) if err != nil { - return reRender(&form) + return fail(&form) } if !form.Validate() { - return reRender(&form) + return fail(&form) } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) @@ -218,7 +217,7 @@ func (app *application) postResetPassword(w http.ResponseWriter, r *http.Request default: form.AddError("password", "Invalid or expired reset link.") } - return reRender(&form) + return fail(&form) } if err := app.services.accounts.SendPasswordChangedNotification(ctx, email); err != nil { diff --git a/cmd/web/handlers:equipment:manufacturers.go b/cmd/web/handlers:manufacturers.go similarity index 65% rename from cmd/web/handlers:equipment:manufacturers.go rename to cmd/web/handlers:manufacturers.go index baa867f..1242a6c 100644 --- a/cmd/web/handlers:equipment:manufacturers.go +++ b/cmd/web/handlers:manufacturers.go @@ -20,12 +20,11 @@ import ( "net/http" "net/url" - "github.com/bit8bytes/gearberg/internal/equipment/manufacturers" "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/manufacturers" "github.com/bit8bytes/gearberg/internal/templates/fragments" "github.com/bit8bytes/gearberg/internal/templates/pages" "github.com/bit8bytes/gearberg/pkg/htmx" - "github.com/segmentio/ksuid" ) type manufacturersData struct { @@ -47,11 +46,7 @@ func (app *application) getManufacturers(w http.ResponseWriter, r *http.Request) mfrs, err := app.services.manufacturers.List(ctx, id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve manufacturers.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) @@ -59,7 +54,7 @@ func (app *application) getManufacturers(w http.ResponseWriter, r *http.Request) data.Data = manufacturersData{ OrgID: id, Manufacturers: mfrs, - MaxManufacturers: app.services.manufacturers.MaxManufacturers(), + MaxManufacturers: app.options.Limits.MaxOrgManufacturers, } return app.html.Render(w, r, http.StatusOK, pages.ManufacturersIndex, data) } @@ -78,10 +73,10 @@ func (app *application) postManufacturerNew(w http.ResponseWriter, r *http.Reque form, err := manufacturers.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *manufacturers.Form) *httperr.Error { + fail := func(f *manufacturers.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = manufacturerData{OrgID: id} @@ -89,29 +84,24 @@ func (app *application) postManufacturerNew(w http.ResponseWriter, r *http.Reque } if !form.Validate() { - return reRender(&form) + return fail(&form) } _, err = app.services.manufacturers.Create(ctx, manufacturers.CreateManufacturer{ - ID: ksuid.New().String(), OrgID: id, Name: form.Name, }) if err != nil { if errors.Is(err, manufacturers.ErrConflict) { form.AddError("name", "A manufacturer with this name already exists.") - return reRender(&form) + return fail(&form) } if errors.Is(err, manufacturers.ErrLimitExceeded) { - limit := app.services.manufacturers.MaxManufacturers() + limit := app.options.Limits.MaxOrgManufacturers form.AddError("name", fmt.Sprintf("Manufacturer limit reached. Only %d manufacturers allowed per org.", limit)) - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to create manufacturer.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } dest := "/orgs/" + url.PathEscape(id) + "/settings/manufacturers" @@ -124,21 +114,20 @@ func (app *application) getManufacturer(w http.ResponseWriter, r *http.Request) orgID := r.PathValue("org_id") mfrID := r.PathValue("id") - manufacturer, err := app.services.manufacturers.GetByID(ctx, mfrID) + manufacturer, err := app.services.manufacturers.Get(ctx, mfrID) if err != nil { if errors.Is(err, manufacturers.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Manufacturer not found.", Code: http.StatusNotFound} - } - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve manufacturer.", - Code: http.StatusInternalServerError, + return httperr.NotFound(err) } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) data.Form = &manufacturers.Form{Name: manufacturer.Name} - data.Data = manufacturerData{OrgID: orgID, ID: mfrID} + data.Data = manufacturerData{ + OrgID: orgID, + ID: mfrID, + } return app.html.Render(w, r, http.StatusOK, pages.ManufacturersDetail, data) } @@ -148,18 +137,18 @@ func (app *application) postManufacturer(w http.ResponseWriter, r *http.Request) form, err := manufacturers.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - mfr, err := app.services.manufacturers.GetByID(ctx, mfrID) + mfr, err := app.services.manufacturers.Get(ctx, mfrID) if err != nil { if errors.Is(err, manufacturers.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Manufacturer not found.", Code: http.StatusNotFound} + return httperr.NotFound(err) } - return &httperr.Error{Error: err, Message: "Failed to retrieve manufacturer.", Code: http.StatusInternalServerError} + return httperr.InternalServerError(err) } - reRender := func(f *manufacturers.Form) *httperr.Error { + fail := func(f *manufacturers.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = manufacturerData{OrgID: mfr.OrgID, ID: mfrID} @@ -167,7 +156,7 @@ func (app *application) postManufacturer(w http.ResponseWriter, r *http.Request) } if !form.Validate() { - return reRender(&form) + return fail(&form) } _, err = app.services.manufacturers.Update(ctx, manufacturers.UpdateManufacturer{ @@ -177,13 +166,9 @@ func (app *application) postManufacturer(w http.ResponseWriter, r *http.Request) if err != nil { if errors.Is(err, manufacturers.ErrConflict) { form.AddError("name", "A manufacturer with this name already exists.") - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to update manufacturer.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } dest := "/orgs/" + mfr.OrgID + "/settings/manufacturers/" + mfr.ID @@ -197,26 +182,22 @@ func (app *application) postDeleteManufacturer(w http.ResponseWriter, r *http.Re mfrID := r.PathValue("id") if err := app.services.manufacturers.Delete(ctx, mfrID); err != nil { - if errors.Is(err, manufacturers.ErrInUse) { - manufacturer, fetchErr := app.services.manufacturers.GetByID(ctx, mfrID) - if fetchErr != nil { - if errors.Is(fetchErr, manufacturers.ErrNotFound) { - return &httperr.Error{Error: fetchErr, Message: "Manufacturer not found.", Code: http.StatusNotFound} - } - return &httperr.Error{Error: fetchErr, Message: "Failed to retrieve manufacturer.", Code: http.StatusInternalServerError} - } - f := &manufacturers.Form{Name: manufacturer.Name} - f.AddError("delete", "Cannot delete: this manufacturer is assigned to one or more inventory items.") - data := app.html.TemplateData(r) - data.Form = f - data.Data = manufacturerData{OrgID: orgID, ID: mfrID} - return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ManufacturersDetail, data) + if !errors.Is(err, manufacturers.ErrInUse) { + return httperr.InternalServerError(err) } - return &httperr.Error{ - Error: err, - Message: "Failed to delete manufacturer.", - Code: http.StatusInternalServerError, + manufacturer, fetchErr := app.services.manufacturers.Get(ctx, mfrID) + if fetchErr != nil { + if errors.Is(fetchErr, manufacturers.ErrNotFound) { + return httperr.NotFound(err) + } + return httperr.InternalServerError(fetchErr) } + f := &manufacturers.Form{Name: manufacturer.Name} + f.AddError("delete", "Cannot delete: this manufacturer is assigned to one or more inventory items.") + data := app.html.TemplateData(r) + data.Form = f + data.Data = manufacturerData{OrgID: orgID, ID: mfrID} + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ManufacturersDetail, data) } dest := "/orgs/" + url.PathEscape(orgID) + "/settings/manufacturers" @@ -238,11 +219,7 @@ func (app *application) getEquipmentManufacturersFragment(w http.ResponseWriter, mfrs, err := app.services.manufacturers.List(ctx, orgID) if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("getEquipmentManufacturersFragment: %w", err), - Message: "Failed to retrieve manufacturers.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("getEquipmentManufacturersFragment: %w", err)) } tmplData := app.html.TemplateData(r) diff --git a/cmd/web/handlers:media.go b/cmd/web/handlers:media.go index b5f62b5..09201cb 100644 --- a/cmd/web/handlers:media.go +++ b/cmd/web/handlers:media.go @@ -26,20 +26,12 @@ func (app *application) getMedia(w http.ResponseWriter, r *http.Request) *httper obj, err := app.services.storageManager.Get(r.Context(), id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Media not found.", - Code: http.StatusNotFound, - } + return httperr.NotFound(err) } rc, err := app.services.storageManager.Open(r.Context(), id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to open media.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } defer func(rc io.ReadCloser) { if err := rc.Close(); err != nil { @@ -49,11 +41,7 @@ func (app *application) getMedia(w http.ResponseWriter, r *http.Request) *httper w.Header().Set("Content-Type", obj.ContentType) if _, err := io.Copy(w, rc); err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to serve media.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } return nil diff --git a/cmd/web/handlers:orgs.go b/cmd/web/handlers:orgs.go index b2e6fd7..cf43f22 100644 --- a/cmd/web/handlers:orgs.go +++ b/cmd/web/handlers:orgs.go @@ -39,11 +39,7 @@ func (app *application) getOrgs(w http.ResponseWriter, r *http.Request) *httperr allOrgs, err := app.services.orgs.List(ctx, session.AccountID) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve orgs.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) @@ -72,25 +68,20 @@ func (app *application) getSettingsOrg(w http.ResponseWriter, r *http.Request) * org, err := app.services.orgs.Get(ctx, id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve org.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } usage, err := app.services.storageManager.Info(ctx, id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve storage info.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) data.Form = &orgs.Form{DisplayName: org.DisplayName} - data.Data = settingsOrgData{OrgID: id, StorageUsage: usage} + data.Data = settingsOrgData{ + OrgID: id, + StorageUsage: usage, + } return app.html.Render(w, r, http.StatusOK, pages.OrgSettingsDetails, data) } @@ -119,15 +110,15 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) ctx := r.Context() id, err := uid.Parse(r.PathValue("org_id")) if err != nil { - return &httperr.Error{Error: err, Message: "Invalid org ID.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } form, err := orgs.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *orgs.Form) *httperr.Error { + fail := func(f *orgs.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = settingsOrgData{OrgID: id} @@ -135,7 +126,7 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) } if !form.Validate() { - return reRender(&form) + return fail(&form) } err = app.services.orgs.Update(ctx, orgs.UpdateParams{ @@ -145,13 +136,9 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) if err != nil { if errors.Is(err, orgs.ErrConflict) { form.AddError("name", "A org with this name already exists.") - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to update org.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } http.Redirect(w, r, "/orgs/"+id, http.StatusSeeOther) //nolint:gosec // id is a parsed and validated KSUID, not an open redirect @@ -163,17 +150,17 @@ func (app *application) postOrgsNew(w http.ResponseWriter, r *http.Request) *htt form, err := orgs.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *orgs.Form) *httperr.Error { + fail := func(f *orgs.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.OrgsNew, data) } if !form.Validate() { - return reRender(&form) + return fail(&form) } session := sessions.MustFromRequest(r) @@ -185,26 +172,18 @@ func (app *application) postOrgsNew(w http.ResponseWriter, r *http.Request) *htt if err != nil { if errors.Is(err, orgs.ErrConflict) { form.AddError("name", "A org with this name already exists.") - return reRender(&form) + return fail(&form) } if errors.Is(err, orgs.ErrLimitExceeded) { form.AddError("name", fmt.Sprintf("Org limit reached. Only %d orgs allowed.", app.options.Limits.MaxOrgs)) - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to create org.", - Code: http.StatusInternalServerError, + return fail(&form) } + return httperr.InternalServerError(err) } - _, err = app.services.orgsettings.Create(ctx, uid.New(), orgID) + _, err = app.services.orgsettings.Create(ctx, orgID) if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("postOrgsNew: seed default settings: %w", err), - Message: "Failed to initialise org settings.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("postOrgsNew: seed default settings: %w", err)) } http.Redirect(w, r, fmt.Sprintf("/orgs/%s/equipment", orgID), http.StatusSeeOther) diff --git a/cmd/web/handlers:orgs:settings.go b/cmd/web/handlers:orgs:settings.go index 6f60e03..22df88f 100644 --- a/cmd/web/handlers:orgs:settings.go +++ b/cmd/web/handlers:orgs:settings.go @@ -15,7 +15,6 @@ package main import ( - "fmt" "net/http" "net/url" @@ -24,7 +23,6 @@ import ( "github.com/bit8bytes/gearberg/internal/orgs/settings" "github.com/bit8bytes/gearberg/internal/templates/fragments" "github.com/bit8bytes/gearberg/internal/templates/pages" - "github.com/bit8bytes/gearberg/internal/timezone" "github.com/bit8bytes/gearberg/pkg/htmx" ) @@ -47,11 +45,7 @@ func (app *application) getOrgCurrencyFragment(w http.ResponseWriter, r *http.Re s, err := app.services.orgsettings.Get(ctx, orgID) if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("getOrgCurrencyFragment: %w", err), - Message: "Failed to retrieve org settings.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } var currency money.Currency @@ -70,15 +64,11 @@ func (app *application) getOrgSettings(w http.ResponseWriter, r *http.Request) * s, err := app.services.orgsettings.Get(ctx, id) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to retrieve org settings.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) - f := settings.FormFromOrgSettings(s) + f := s.Form() data.Form = &f data.Data = orgSettingsData{OrgID: id} return app.html.Render(w, r, http.StatusOK, pages.OrgSettings, data) @@ -90,10 +80,10 @@ func (app *application) postOrgSettings(w http.ResponseWriter, r *http.Request) form, err := settings.Parse(r) if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + return httperr.BadRequest(err) } - reRender := func(f *settings.Form) *httperr.Error { + fail := func(f *settings.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f data.Data = orgSettingsData{OrgID: id} @@ -101,25 +91,21 @@ func (app *application) postOrgSettings(w http.ResponseWriter, r *http.Request) } if !form.Validate() { - return reRender(&form) + return fail(&form) } s, err := app.services.orgsettings.Update(ctx, settings.UpdateOrgSettings{ OrgID: id, - Currency: money.Currency(form.Currency), - VatRate: form.ParsedVatRate(), - Timezone: timezone.Timezone(form.Timezone), + Currency: form.Currency, + VatRate: form.VatRate, + Timezone: form.Timezone, }) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to save org settings.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } data := app.html.TemplateData(r) - f := settings.FormFromOrgSettings(s) + f := s.Form() data.Form = &f data.Data = orgSettingsData{OrgID: id} return app.html.Render(w, r, http.StatusOK, pages.OrgSettings, data) diff --git a/cmd/web/handlers:settings:account.go b/cmd/web/handlers:settings:account.go index 51b65e6..83bb1ee 100644 --- a/cmd/web/handlers:settings:account.go +++ b/cmd/web/handlers:settings:account.go @@ -38,11 +38,7 @@ func (app *application) getAccount(w http.ResponseWriter, r *http.Request) *http accountID := sessionAccountID(ctx, app.session) record, err := app.services.accounts.Get(ctx, accountID) if err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to load account.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(err) } tmplData := app.html.TemplateData(r) diff --git a/cmd/web/seed.go b/cmd/web/seed.go index 643afb4..3ddcc11 100644 --- a/cmd/web/seed.go +++ b/cmd/web/seed.go @@ -21,6 +21,8 @@ import ( "github.com/bit8bytes/gearberg/internal/credentials" "github.com/bit8bytes/gearberg/internal/equipment" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipment/usage" "github.com/bit8bytes/gearberg/internal/federated" "github.com/bit8bytes/gearberg/internal/orgs" "github.com/bit8bytes/gearberg/internal/tokens" @@ -61,9 +63,9 @@ func seedReferenceData(ctx context.Context, db *sql.DB) error { //nolint:cyclop } } - for _, t := range []equipment.TrackingType{ - equipment.Bulk, - equipment.Serialized, + for _, t := range []tracking.Type{ + tracking.Bulk, + tracking.Serialized, } { if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO tracking_types (id, name) VALUES (?, ?)`, t.ID(), t.String()); err != nil { return fmt.Errorf("seed tracking_types: %w", err) @@ -71,16 +73,16 @@ func seedReferenceData(ctx context.Context, db *sql.DB) error { //nolint:cyclop } for _, t := range []equipment.Type{ - equipment.Standard, - equipment.Kit, + equipment.StandardType, + equipment.KitType, } { if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO equipment_types (id, name) VALUES (?, ?)`, t.ID(), t.String()); err != nil { return fmt.Errorf("seed equipment_types: %w", err) } } - for _, u := range []equipment.UsageType{ - equipment.Rental, + for _, u := range []usage.Type{ + usage.Rental, } { if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO usage_types (id, name) VALUES (?, ?)`, u.ID(), u.String()); err != nil { return fmt.Errorf("seed usage_types: %w", err) diff --git a/cmd/web/setup.go b/cmd/web/setup.go index 2a9d266..f326c6e 100644 --- a/cmd/web/setup.go +++ b/cmd/web/setup.go @@ -33,15 +33,15 @@ import ( "github.com/alexedwards/scs/v2" "github.com/bit8bytes/gearberg/internal/accounts" + "github.com/bit8bytes/gearberg/internal/categories" "github.com/bit8bytes/gearberg/internal/credentials" "github.com/bit8bytes/gearberg/internal/database" "github.com/bit8bytes/gearberg/internal/database/migrations" "github.com/bit8bytes/gearberg/internal/equipment" - "github.com/bit8bytes/gearberg/internal/equipment/categories" - invimports "github.com/bit8bytes/gearberg/internal/equipment/imports" - "github.com/bit8bytes/gearberg/internal/equipment/locations" - "github.com/bit8bytes/gearberg/internal/equipment/manufacturers" + invimports "github.com/bit8bytes/gearberg/internal/equipmentimports" "github.com/bit8bytes/gearberg/internal/federated" + "github.com/bit8bytes/gearberg/internal/locations" + "github.com/bit8bytes/gearberg/internal/manufacturers" "github.com/bit8bytes/gearberg/internal/orgs" "github.com/bit8bytes/gearberg/internal/orgs/settings" "github.com/bit8bytes/gearberg/internal/storage" @@ -266,10 +266,10 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*s locationsSvc := locations.NewService(locationsRepo, locations.Options{MaxLocations: opts.Limits.MaxOrgLocations}) inventoryRepo := equipment.NewRepository(db) - inventorySvc := equipment.NewService(inventoryRepo, db, equipmentcategoriesSvc, manufacturersSvc, locationsSvc) + inventorySvc := equipment.NewService(inventoryRepo, db) equipmentImportsRepo := invimports.NewRepository(db) - equipmentImportsSvc := invimports.NewService(equipmentImportsRepo, db, inventorySvc, equipmentcategoriesSvc, manufacturersSvc, locationsSvc) + equipmentImportsSvc := invimports.NewService(equipmentImportsRepo, db, inventoryRepo, equipmentcategoriesSvc, manufacturersSvc, locationsSvc) store, err := storage.Open("local", opts.StorageDSN, logger) if err != nil { diff --git a/internal/accounts/service.go b/internal/accounts/service.go index 93647db..d30b8aa 100644 --- a/internal/accounts/service.go +++ b/internal/accounts/service.go @@ -27,8 +27,8 @@ import ( "github.com/bit8bytes/gearberg/internal/database" "github.com/bit8bytes/gearberg/internal/orgs" "github.com/bit8bytes/gearberg/internal/tokens" + "github.com/bit8bytes/gearberg/internal/uid" pkgtokens "github.com/bit8bytes/gearberg/pkg/tokens" - "github.com/segmentio/ksuid" ) // Sentinel errors returned by account service operations. @@ -154,7 +154,7 @@ func (s *Service) SignUp(ctx context.Context, params SignUpParams) (string, stri } defer func() { _ = tx.Rollback() }() - accountID := ksuid.New().String() + accountID := uid.New() if _, err := s.accounts.Create(ctx, tx, accountID, params.Email, nil); err != nil { return fail(err) } @@ -163,7 +163,7 @@ func (s *Service) SignUp(ctx context.Context, params SignUpParams) (string, stri return fail(err) } - orgID := ksuid.New().String() + orgID := uid.New() if _, err := s.organizations.Create(ctx, tx, accountID, orgID, "My Organization"); err != nil { return fail(err) } @@ -356,12 +356,12 @@ func (s *Service) SignInOrCreateWithOIDC(ctx context.Context, providerID int64, emailVerifiedAt = &now } - accountID = ksuid.New().String() + accountID = uid.New() if _, err := s.accounts.Create(ctx, tx, accountID, email, emailVerifiedAt); err != nil { return fail(err) } - orgID := ksuid.New().String() + orgID := uid.New() if _, err := s.organizations.Create(ctx, tx, accountID, orgID, "My Organization"); err != nil { return fail(err) } diff --git a/internal/equipment/categories/form.go b/internal/categories/form.go similarity index 100% rename from internal/equipment/categories/form.go rename to internal/categories/form.go diff --git a/internal/equipment/categories/repository.go b/internal/categories/repository.go similarity index 86% rename from internal/equipment/categories/repository.go rename to internal/categories/repository.go index 20544ea..335a897 100644 --- a/internal/equipment/categories/repository.go +++ b/internal/categories/repository.go @@ -49,21 +49,21 @@ func (r *Repository) Count(ctx context.Context, orgID string) (int64, error) { return n, nil } -// GetByOrgID returns all equipmentCategories belonging to orgID. -func (r *Repository) GetByOrgID(ctx context.Context, orgID string) ([]EquipmentCategory, error) { +// List returns all equipmentCategories belonging to orgID. +func (r *Repository) List(ctx context.Context, orgID string) ([]Category, error) { rows, err := r.equipmentCategories.GetByOrgID(ctx, sql.NullString{String: orgID, Valid: orgID != ""}) if err != nil { return nil, fmt.Errorf("GetByOrgID: %w", err) } - result := make([]EquipmentCategory, len(rows)) + result := make([]Category, len(rows)) for i, row := range rows { result[i] = toModel(row) } return result, nil } -// GetByID returns the category with id, or ErrNotFound when it does not exist. -func (r *Repository) GetByID(ctx context.Context, id string) (*EquipmentCategory, error) { +// Get returns the category with id, or ErrNotFound when it does not exist. +func (r *Repository) Get(ctx context.Context, id string) (*Category, error) { row, err := r.equipmentCategories.GetByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -75,8 +75,14 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*EquipmentCategory return &m, nil } +type createParams struct { + ID string + OrgID string + Name string +} + // Create inserts a new equipment category. -func (r *Repository) Create(ctx context.Context, c CreateEquipmentCategory) (*EquipmentCategory, error) { +func (r *Repository) Create(ctx context.Context, c createParams) (*Category, error) { row, err := r.equipmentCategories.Create(ctx, genec.CreateParams{ ID: c.ID, OrgID: sql.NullString{String: c.OrgID, Valid: c.OrgID != ""}, @@ -92,7 +98,7 @@ func (r *Repository) Create(ctx context.Context, c CreateEquipmentCategory) (*Eq } return nil, fmt.Errorf("Create: %w", normalized) } - m := EquipmentCategory{ + m := Category{ ID: row.ID, OrgID: row.OrgID.String, Name: row.Name, @@ -102,7 +108,7 @@ func (r *Repository) Create(ctx context.Context, c CreateEquipmentCategory) (*Eq } // Update updates the name of the category identified by u.ID. -func (r *Repository) Update(ctx context.Context, u UpdateEquipmentCategory) (*EquipmentCategory, error) { +func (r *Repository) Update(ctx context.Context, u UpdateEquipmentCategory) (*Category, error) { row, err := r.equipmentCategories.Update(ctx, genec.UpdateParams{ ID: u.ID, Name: u.Name, @@ -120,7 +126,7 @@ func (r *Repository) Update(ctx context.Context, u UpdateEquipmentCategory) (*Eq // GetByName returns the category with the given name within orgID, or ErrNotFound // when it does not exist. -func (r *Repository) GetByName(ctx context.Context, orgID, name string) (*EquipmentCategory, error) { +func (r *Repository) GetByName(ctx context.Context, orgID, name string) (*Category, error) { row, err := r.equipmentCategories.GetByName(ctx, genec.GetByNameParams{ OrgID: sql.NullString{String: orgID, Valid: orgID != ""}, Name: name, @@ -147,8 +153,8 @@ func (r *Repository) Delete(ctx context.Context, id string) error { return nil } -func toModel(row genec.EquipmentCategory) EquipmentCategory { - return EquipmentCategory{ +func toModel(row genec.EquipmentCategory) Category { + return Category{ ID: row.ID, OrgID: row.OrgID.String, Name: row.Name, diff --git a/internal/equipment/categories/service.go b/internal/categories/service.go similarity index 67% rename from internal/equipment/categories/service.go rename to internal/categories/service.go index 4371712..1167723 100644 --- a/internal/equipment/categories/service.go +++ b/internal/categories/service.go @@ -22,7 +22,18 @@ import ( "fmt" "strings" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/uid" +) + +var ( + // ErrNotFound is returned when an equipment category does not exist. + ErrNotFound = errors.New("equipment category not found") + // ErrConflict is returned when a unique constraint is violated. + ErrConflict = errors.New("equipment category already exists") + // ErrLimitExceeded is returned when an org's category limit is reached. + ErrLimitExceeded = errors.New("equipment category limit exceeded") + // ErrInUse is returned when a category cannot be deleted due to assigned inventory items. + ErrInUse = errors.New("equipment category is in use and cannot be deleted") ) // Options holds configuration for the equipment category service. @@ -41,31 +52,41 @@ func NewService(repo *Repository, opts Options) *Service { return &Service{repo: repo, opts: opts} } -// MaxCategories returns the configured maximum number of categories per org. -func (s *Service) MaxCategories() int { - return s.opts.MaxCategories +// Category represents an equipment category belonging to a org. +type Category struct { + ID string + OrgID string + Name string + UpdatedAt int64 + CreatedAt int64 } // List returns all equipmentCategories belonging to orgID. -func (s *Service) List(ctx context.Context, orgID string) ([]EquipmentCategory, error) { - equipmentCategories, err := s.repo.GetByOrgID(ctx, orgID) +func (s *Service) List(ctx context.Context, orgID string) ([]Category, error) { + equipmentCategories, err := s.repo.List(ctx, orgID) if err != nil { return nil, fmt.Errorf("failed to get equipment equipmentCategories: %w", err) } return equipmentCategories, nil } -// GetByID returns the category with id. -func (s *Service) GetByID(ctx context.Context, id string) (*EquipmentCategory, error) { - category, err := s.repo.GetByID(ctx, id) +// Get returns the category with id. +func (s *Service) Get(ctx context.Context, id string) (*Category, error) { + category, err := s.repo.Get(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get equipment category: %w", err) } return category, nil } +// CreateEquipmentCategory holds the data required to create a new equipment category. +type CreateEquipmentCategory struct { + OrgID string + Name string +} + // Create creates a new equipment category, enforcing the configured MaxCategories limit per org. -func (s *Service) Create(ctx context.Context, c CreateEquipmentCategory) (*EquipmentCategory, error) { +func (s *Service) Create(ctx context.Context, c CreateEquipmentCategory) (*Category, error) { count, err := s.repo.Count(ctx, c.OrgID) if err != nil { return nil, fmt.Errorf("failed to create equipment category: %w", err) @@ -74,15 +95,25 @@ func (s *Service) Create(ctx context.Context, c CreateEquipmentCategory) (*Equip return nil, ErrLimitExceeded } - category, err := s.repo.Create(ctx, c) + category, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), + OrgID: c.OrgID, + Name: c.Name, + }) if err != nil { return nil, fmt.Errorf("failed to create equipment category: %w", err) } return category, nil } +// UpdateEquipmentCategory holds the data required to update an equipment category. +type UpdateEquipmentCategory struct { + ID string + Name string +} + // Update updates the name of an equipment category. -func (s *Service) Update(ctx context.Context, u UpdateEquipmentCategory) (*EquipmentCategory, error) { +func (s *Service) Update(ctx context.Context, u UpdateEquipmentCategory) (*Category, error) { category, err := s.repo.Update(ctx, u) if err != nil { return nil, fmt.Errorf("failed to update equipment category: %w", err) @@ -98,8 +129,8 @@ func (s *Service) Upsert(ctx context.Context, orgID, name string) (string, error if name == "" { return "", fmt.Errorf("Upsert: name is blank") } - _, err := s.repo.Create(ctx, CreateEquipmentCategory{ - ID: ksuid.New().String(), + _, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), OrgID: orgID, Name: name, }) diff --git a/internal/database/queries/gen/equipment/equipment.sql.go b/internal/database/queries/gen/equipment/equipment.sql.go index 1ac141a..aceaeb8 100644 --- a/internal/database/queries/gen/equipment/equipment.sql.go +++ b/internal/database/queries/gen/equipment/equipment.sql.go @@ -8,6 +8,8 @@ package equipment import ( "context" "database/sql" + + units "github.com/bit8bytes/gearberg/internal/units" ) const countByOrgID = `-- name: CountByOrgID :one @@ -104,17 +106,17 @@ type CreateParams struct { LocationID sql.NullString Name string IsArchived int64 - RentalPrice sql.NullInt64 - ResalePrice sql.NullInt64 + RentalPrice *units.Cents + ResalePrice *units.Cents Notes sql.NullString - WeightG sql.NullInt64 - WidthMm sql.NullInt64 - HeightMm sql.NullInt64 - DepthMm sql.NullInt64 - VoltageMv sql.NullInt64 - CurrentMa sql.NullInt64 - PowerMw sql.NullInt64 - WireGaugeMm2X100 sql.NullInt64 + WeightG *units.Grams + WidthMm *units.Millimeters + HeightMm *units.Millimeters + DepthMm *units.Millimeters + VoltageMv *units.Millivolts + CurrentMa *units.Milliamps + PowerMw *units.Milliwatts + WireGaugeMm2X100 *units.WireGauge } type CreateRow struct { @@ -129,17 +131,17 @@ type CreateRow struct { StorageObjectID sql.NullString Name string IsArchived int64 - RentalPrice sql.NullInt64 - ResalePrice sql.NullInt64 + RentalPrice *units.Cents + ResalePrice *units.Cents Notes sql.NullString - WeightG sql.NullInt64 - WidthMm sql.NullInt64 - HeightMm sql.NullInt64 - DepthMm sql.NullInt64 - VoltageMv sql.NullInt64 - CurrentMa sql.NullInt64 - PowerMw sql.NullInt64 - WireGaugeMm2X100 sql.NullInt64 + WeightG *units.Grams + WidthMm *units.Millimeters + HeightMm *units.Millimeters + DepthMm *units.Millimeters + VoltageMv *units.Millivolts + CurrentMa *units.Milliamps + PowerMw *units.Milliwatts + WireGaugeMm2X100 *units.WireGauge CreatedAt int64 } @@ -263,17 +265,17 @@ type GetByIDRow struct { IsArchived int64 TotalStock int64 ContentCount int64 - RentalPrice sql.NullInt64 - ResalePrice sql.NullInt64 + RentalPrice *units.Cents + ResalePrice *units.Cents Notes sql.NullString - WeightG sql.NullInt64 - WidthMm sql.NullInt64 - HeightMm sql.NullInt64 - DepthMm sql.NullInt64 - VoltageMv sql.NullInt64 - CurrentMa sql.NullInt64 - PowerMw sql.NullInt64 - WireGaugeMm2X100 sql.NullInt64 + WeightG *units.Grams + WidthMm *units.Millimeters + HeightMm *units.Millimeters + DepthMm *units.Millimeters + VoltageMv *units.Millivolts + CurrentMa *units.Milliamps + PowerMw *units.Milliwatts + WireGaugeMm2X100 *units.WireGauge UpdatedAt int64 CreatedAt int64 } @@ -389,17 +391,17 @@ type ListRow struct { UsageTypeID int64 TotalStock int64 IsArchived int64 - RentalPrice sql.NullInt64 - ResalePrice sql.NullInt64 + RentalPrice *units.Cents + ResalePrice *units.Cents Notes sql.NullString - WeightG sql.NullInt64 - WidthMm sql.NullInt64 - HeightMm sql.NullInt64 - DepthMm sql.NullInt64 - VoltageMv sql.NullInt64 - CurrentMa sql.NullInt64 - PowerMw sql.NullInt64 - WireGaugeMm2X100 sql.NullInt64 + WeightG *units.Grams + WidthMm *units.Millimeters + HeightMm *units.Millimeters + DepthMm *units.Millimeters + VoltageMv *units.Millivolts + CurrentMa *units.Milliamps + PowerMw *units.Milliwatts + WireGaugeMm2X100 *units.WireGauge UpdatedAt int64 CreatedAt int64 TotalRecords int64 @@ -533,8 +535,8 @@ type ListBySerialNumberRow struct { UsageTypeID int64 TotalStock int64 IsArchived int64 - RentalPrice sql.NullInt64 - ResalePrice sql.NullInt64 + RentalPrice *units.Cents + ResalePrice *units.Cents Notes sql.NullString UpdatedAt int64 CreatedAt int64 @@ -655,8 +657,8 @@ WHERE id = ?3 ` type UpdatePricingParams struct { - RentalPrice sql.NullInt64 - ResalePrice sql.NullInt64 + RentalPrice *units.Cents + ResalePrice *units.Cents ID string } @@ -681,14 +683,14 @@ WHERE id = ?9 ` type UpdatePropertiesParams struct { - WeightG sql.NullInt64 - WidthMm sql.NullInt64 - HeightMm sql.NullInt64 - DepthMm sql.NullInt64 - VoltageMv sql.NullInt64 - CurrentMa sql.NullInt64 - PowerMw sql.NullInt64 - WireGaugeMm2X100 sql.NullInt64 + WeightG *units.Grams + WidthMm *units.Millimeters + HeightMm *units.Millimeters + DepthMm *units.Millimeters + VoltageMv *units.Millivolts + CurrentMa *units.Milliamps + PowerMw *units.Milliwatts + WireGaugeMm2X100 *units.WireGauge ID string } diff --git a/internal/equipment/categories/model.go b/internal/equipment/categories/model.go deleted file mode 100644 index d48a8cf..0000000 --- a/internal/equipment/categories/model.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (C) 2026 Tobias Gleiter -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Package categories handles equipment category routes, business logic, and data access. -package categories - -import "errors" - -var ( - // ErrNotFound is returned when an equipment category does not exist. - ErrNotFound = errors.New("equipment category not found") - // ErrConflict is returned when a unique constraint is violated. - ErrConflict = errors.New("equipment category already exists") - // ErrLimitExceeded is returned when an org's category limit is reached. - ErrLimitExceeded = errors.New("equipment category limit exceeded") - // ErrInUse is returned when a category cannot be deleted due to assigned inventory items. - ErrInUse = errors.New("equipment category is in use and cannot be deleted") -) - -// EquipmentCategory represents an equipment category belonging to a org. -type EquipmentCategory struct { - ID string - OrgID string - Name string - UpdatedAt int64 - CreatedAt int64 -} - -// CreateEquipmentCategory holds the data required to create a new equipment category. -type CreateEquipmentCategory struct { - ID string - OrgID string - Name string -} - -// UpdateEquipmentCategory holds the data required to update an equipment category. -type UpdateEquipmentCategory struct { - ID string - Name string -} diff --git a/internal/equipment/form.go b/internal/equipment/form.go index 05a137f..60bbd12 100644 --- a/internal/equipment/form.go +++ b/internal/equipment/form.go @@ -22,6 +22,9 @@ import ( "net/http" "strings" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipment/usage" + "github.com/bit8bytes/gearberg/internal/units" "github.com/bit8bytes/toolbox/validator" ) @@ -29,7 +32,7 @@ const maxUploadBytes = 10 << 20 // 10 MiB // DetailsForm holds parsed input and validation state for the details tab. type DetailsForm struct { - TypeID string // hidden field: "bulk" or "serialized" + Type tracking.Type Name string CategoryID string CategoryName string // set when user typed a new category name not yet in the DB @@ -46,21 +49,21 @@ type DetailsForm struct { // PricingForm holds parsed input and validation state for the pricing tab. type PricingForm struct { - PurchasePrice string - RentalPrice string + PurchasePrice *units.Cents + RentalPrice *units.Cents validator.Validator } // PropertiesForm holds parsed input and validation state for the properties tab. type PropertiesForm struct { - Weight string - Width string - Height string - Depth string - Power string - Current string - Voltage string - WireGauge string + Weight *units.Grams + Width *units.Millimeters + Height *units.Millimeters + Depth *units.Millimeters + Power *units.Milliwatts + Current *units.Milliamps + Voltage *units.Millivolts + WireGauge *units.WireGauge validator.Validator } @@ -71,7 +74,7 @@ func ParseDetails(r *http.Request) (DetailsForm, error) { if err := r.ParseMultipartForm(maxUploadBytes); err != nil { //nolint:gosec // maxUploadBytes is a bounded constant (10 MiB) return f, fmt.Errorf("parse form: %w", err) } - f.TypeID = strings.TrimSpace(r.PostForm.Get("type_id")) + f.Type = tracking.Parse(strings.TrimSpace(r.PostForm.Get("type_id"))) f.Name = strings.TrimSpace(r.PostForm.Get("name")) f.CategoryID = strings.TrimSpace(r.PostForm.Get("category_id")) f.CategoryName = strings.TrimSpace(r.PostForm.Get("category_name")) @@ -94,7 +97,7 @@ func (f *DetailsForm) Validate() bool { f.Check(validator.NotBlank(f.Name), "name", "This field cannot be blank") f.Check(validator.MaxChars(f.Name, 200), "name", "This field cannot exceed 200 characters") - if f.TypeID == "bulk" { + if f.Type == tracking.Bulk { f.Check(f.TotalStock >= 1, "total_stock", "Must be at least 1") } @@ -108,32 +111,22 @@ func ParsePricing(r *http.Request) (PricingForm, error) { if err := r.ParseForm(); err != nil { return f, fmt.Errorf("parse form: %w", err) } - f.PurchasePrice = strings.TrimSpace(r.PostForm.Get("purchase_price")) - f.RentalPrice = strings.TrimSpace(r.PostForm.Get("rental_price")) + f.PurchasePrice = units.ParseCents(r.PostForm.Get("purchase_price")) + f.RentalPrice = units.ParseCents(r.PostForm.Get("rental_price")) return f, nil } // Validate checks PricingForm fields and returns true when all pass. func (f *PricingForm) Validate() bool { - if validator.NotBlank(f.PurchasePrice) { - p := ParseCents(f.PurchasePrice) - f.Check(p != nil, "purchase_price", "Must be a valid number") - f.Check(p == nil || *p > 0, "purchase_price", "Must be greater than 0") + if f.PurchasePrice != nil { + f.Check(*f.PurchasePrice > 0, "purchase_price", "Must be greater than 0") } - if validator.NotBlank(f.RentalPrice) { - r := ParseCents(f.RentalPrice) - f.Check(r != nil, "rental_price", "Must be a valid number") - f.Check(r == nil || *r > 0, "rental_price", "Must be greater than 0") + if f.RentalPrice != nil { + f.Check(*f.RentalPrice > 0, "rental_price", "Must be greater than 0") } return f.Valid() } -// ParsedPurchasePrice parses the purchase price string to Cents. Call only after Validate(). -func (f *PricingForm) ParsedPurchasePrice() *Cents { return ParseCents(f.PurchasePrice) } - -// ParsedRentalPrice parses the rental price string to Cents. Call only after Validate(). -func (f *PricingForm) ParsedRentalPrice() *Cents { return ParseCents(f.RentalPrice) } - // ParseProperties reads the properties-tab form fields from r. func ParseProperties(r *http.Request) (PropertiesForm, error) { f := PropertiesForm{} @@ -141,62 +134,46 @@ func ParseProperties(r *http.Request) (PropertiesForm, error) { if err := r.ParseForm(); err != nil { return f, fmt.Errorf("parse form: %w", err) } - f.Weight = strings.TrimSpace(r.PostForm.Get("weight_kg")) - f.Width = strings.TrimSpace(r.PostForm.Get("width_cm")) - f.Height = strings.TrimSpace(r.PostForm.Get("height_cm")) - f.Depth = strings.TrimSpace(r.PostForm.Get("depth_cm")) - f.Power = strings.TrimSpace(r.PostForm.Get("power_w")) - f.Current = strings.TrimSpace(r.PostForm.Get("current_a")) - f.Voltage = strings.TrimSpace(r.PostForm.Get("voltage_v")) - f.WireGauge = strings.TrimSpace(r.PostForm.Get("wire_gauge_mm2_x100")) + f.Weight = units.ParseGrams(r.PostForm.Get("weight_kg")) + f.Width = units.ParseMillimeters(r.PostForm.Get("width_cm")) + f.Height = units.ParseMillimeters(r.PostForm.Get("height_cm")) + f.Depth = units.ParseMillimeters(r.PostForm.Get("depth_cm")) + f.Power = units.ParseMilliwatts(r.PostForm.Get("power_w")) + f.Current = units.ParseMilliamps(r.PostForm.Get("current_a")) + f.Voltage = units.ParseVolts(r.PostForm.Get("voltage_v")) + f.WireGauge = units.ParseWireGauge(r.PostForm.Get("wire_gauge_mm2_x100")) return f, nil } // Validate checks PropertiesForm fields and returns true when all pass. -// All fields are optional; when provided they must be valid numbers greater than 0. +// All fields are optional; when provided they must be greater than 0. // The high cyclomatic complexity is mechanical repetition across 8 independent fields, not branching logic. // //nolint:cyclop func (f *PropertiesForm) Validate() bool { - if validator.NotBlank(f.Weight) { - v := ParseGrams(f.Weight) - f.Check(v != nil, "weight_kg", "Must be a valid number") - f.Check(v == nil || *v > 0, "weight_kg", "Must be greater than 0") + if f.Weight != nil { + f.Check(*f.Weight > 0, "weight_kg", "Must be greater than 0") } - if validator.NotBlank(f.Width) { - v := ParseMillimeters(f.Width) - f.Check(v != nil, "width_cm", "Must be a valid number") - f.Check(v == nil || *v > 0, "width_cm", "Must be greater than 0") + if f.Width != nil { + f.Check(*f.Width > 0, "width_cm", "Must be greater than 0") } - if validator.NotBlank(f.Height) { - v := ParseMillimeters(f.Height) - f.Check(v != nil, "height_cm", "Must be a valid number") - f.Check(v == nil || *v > 0, "height_cm", "Must be greater than 0") + if f.Height != nil { + f.Check(*f.Height > 0, "height_cm", "Must be greater than 0") } - if validator.NotBlank(f.Depth) { - v := ParseMillimeters(f.Depth) - f.Check(v != nil, "depth_cm", "Must be a valid number") - f.Check(v == nil || *v > 0, "depth_cm", "Must be greater than 0") + if f.Depth != nil { + f.Check(*f.Depth > 0, "depth_cm", "Must be greater than 0") } - if validator.NotBlank(f.Power) { - v := ParseMilliwatts(f.Power) - f.Check(v != nil, "power_w", "Must be a valid number") - f.Check(v == nil || *v > 0, "power_w", "Must be greater than 0") + if f.Power != nil { + f.Check(*f.Power > 0, "power_w", "Must be greater than 0") } - if validator.NotBlank(f.Current) { - v := ParseMilliamps(f.Current) - f.Check(v != nil, "current_a", "Must be a valid number") - f.Check(v == nil || *v > 0, "current_a", "Must be greater than 0") + if f.Current != nil { + f.Check(*f.Current > 0, "current_a", "Must be greater than 0") } - if validator.NotBlank(f.Voltage) { - v := ParseVolts(f.Voltage) - f.Check(v != nil, "voltage_v", "Must be a valid number") - f.Check(v == nil || *v > 0, "voltage_v", "Must be greater than 0") + if f.Voltage != nil { + f.Check(*f.Voltage > 0, "voltage_v", "Must be greater than 0") } - if validator.NotBlank(f.WireGauge) { - v := ParseWireGauge(f.WireGauge) - f.Check(v != nil, "wire_gauge_mm2_x100", "Must be a valid number") - f.Check(v == nil || *v > 0, "wire_gauge_mm2_x100", "Must be greater than 0") + if f.WireGauge != nil { + f.Check(*f.WireGauge > 0, "wire_gauge_mm2_x100", "Must be greater than 0") } return f.Valid() } @@ -204,29 +181,29 @@ func (f *PropertiesForm) Validate() bool { // ToProperties converts the form's parsed values into a Properties sub-struct. func (f *PropertiesForm) ToProperties() Properties { return Properties{ - Weight: ParseGrams(f.Weight), - Width: ParseMillimeters(f.Width), - Height: ParseMillimeters(f.Height), - Depth: ParseMillimeters(f.Depth), - Power: ParseMilliwatts(f.Power), - Current: ParseMilliamps(f.Current), - Voltage: ParseVolts(f.Voltage), - WireGauge: ParseWireGauge(f.WireGauge), + Weight: f.Weight, + Width: f.Width, + Height: f.Height, + Depth: f.Depth, + Power: f.Power, + Current: f.Current, + Voltage: f.Voltage, + WireGauge: f.WireGauge, } } // ToPricing converts the form's parsed values into a Pricing sub-struct. func (f *PricingForm) ToPricing() Pricing { return Pricing{ - PurchasePrice: f.ParsedPurchasePrice(), - RentalPrice: f.ParsedRentalPrice(), + PurchasePrice: f.PurchasePrice, + RentalPrice: f.RentalPrice, } } -// DetailsFormFromEquipment pre-populates a DetailsForm from an existing Equipment's stored values. -func DetailsFormFromEquipment(e *Equipment) DetailsForm { +// DetailsForm pre-populates a DetailsForm from the equipment's stored values. +func (e *Equipment) DetailsForm() DetailsForm { f := DetailsForm{ - TypeID: e.TrackingType.String(), + Type: e.TrackingType, Name: e.Name, CategoryID: e.CategoryID, ManufacturerID: e.ManufacturerID, @@ -238,27 +215,27 @@ func DetailsFormFromEquipment(e *Equipment) DetailsForm { return f } -// PricingFormFromEquipment pre-populates a PricingForm from an existing Equipment's stored values. -func PricingFormFromEquipment(e *Equipment) PricingForm { +// PricingForm pre-populates a PricingForm from the equipment's stored values. +func (e *Equipment) PricingForm() PricingForm { f := PricingForm{ - PurchasePrice: e.Pricing.PurchasePrice.ToDecimal(), - RentalPrice: e.Pricing.RentalPrice.ToDecimal(), + PurchasePrice: e.Pricing.PurchasePrice, + RentalPrice: e.Pricing.RentalPrice, } f.Errors = make(map[string]string) return f } -// PropertiesFormFromEquipment pre-populates a PropertiesForm from an existing Equipment's stored values. -func PropertiesFormFromEquipment(e *Equipment) PropertiesForm { +// PropertiesForm pre-populates a PropertiesForm from the equipment's stored values. +func (e *Equipment) PropertiesForm() PropertiesForm { f := PropertiesForm{ - Weight: e.Properties.Weight.ToKG(), - Width: e.Properties.Width.ToCM(), - Height: e.Properties.Height.ToCM(), - Depth: e.Properties.Depth.ToCM(), - Power: e.Properties.Power.ToW(), - Current: e.Properties.Current.ToA(), - Voltage: e.Properties.Voltage.ToV(), - WireGauge: e.Properties.WireGauge.String(), + Weight: e.Properties.Weight, + Width: e.Properties.Width, + Height: e.Properties.Height, + Depth: e.Properties.Depth, + Power: e.Properties.Power, + Current: e.Properties.Current, + Voltage: e.Properties.Voltage, + WireGauge: e.Properties.WireGauge, } f.Errors = make(map[string]string) return f @@ -270,7 +247,7 @@ type UnitForm struct { ManufacturerSerialNumber string Remark string Quantity int64 - PurchasePrice string + PurchasePrice *units.Cents PurchasedAt *int64 // Unix timestamp NextInspectionAt *int64 // Unix timestamp StatusID int64 @@ -288,10 +265,14 @@ func ParseUnit(r *http.Request) (UnitForm, error) { f.ManufacturerSerialNumber = strings.TrimSpace(r.PostForm.Get("serial_number")) f.Remark = strings.TrimSpace(r.PostForm.Get("remark")) f.Quantity = ParseQuantity(r.PostForm.Get("quantity")) - f.PurchasePrice = strings.TrimSpace(r.PostForm.Get("purchase_price")) + f.PurchasePrice = units.ParseCents(r.PostForm.Get("purchase_price")) f.PurchasedAt = ParseDate(r.PostForm.Get("purchased_at")) f.NextInspectionAt = ParseDate(r.PostForm.Get("next_inspection_at")) - f.StatusID = parseCheckbox(r.PostForm.Get("status_id")) + if strings.TrimSpace(r.PostForm.Get("status_id")) != "" { + f.StatusID = 1 + } else { + f.StatusID = 0 + } return f, nil } @@ -302,13 +283,10 @@ func (f *UnitForm) Validate() bool { return f.Valid() } -// ParsedPurchasePrice parses the purchase price string to Cents. Call only after Validate(). -func (f *UnitForm) ParsedPurchasePrice() *Cents { return ParseCents(f.PurchasePrice) } - // NewForm holds the parsed form input and validation state for inventory creation (both types). type NewForm struct { - TypeID string // "bulk", "serialized", or "kit" - UsageTypeID string // "rental" or "sale" + ItemType string // "bulk", "serialized", or "kit" + UsageType usage.Type Name string CategoryID string CategoryName string // set when user typed a new category name not yet in the DB @@ -317,30 +295,30 @@ type NewForm struct { LocationID string LocationName string // set when user typed a new location name not yet in the DB Count int64 // total_stock for bulk; number of units to generate for serialized/kit - PurchasePrice string - RentalPrice string + PurchasePrice *units.Cents + RentalPrice *units.Cents Notes string Image multipart.File ImageHeader *multipart.FileHeader validator.Validator } -// NewNewForm returns a NewForm with an initialized Errors map, safe for template rendering. -func NewNewForm() *NewForm { +// NewCreateForm returns a NewForm with an initialized Errors map, safe for template rendering. +func NewCreateForm() *NewForm { f := &NewForm{} f.Errors = make(map[string]string) return f } -// ParseNew reads the unified create form fields from r. -func ParseNew(r *http.Request) (NewForm, error) { +// ParseForm reads the unified create form fields from r. +func ParseForm(r *http.Request) (NewForm, error) { f := NewForm{} f.Errors = make(map[string]string) if err := r.ParseMultipartForm(maxUploadBytes); err != nil { //nolint:gosec // maxUploadBytes is a bounded constant (10 MiB) return f, fmt.Errorf("parse form: %w", err) } - f.TypeID = strings.TrimSpace(r.PostForm.Get("type_id")) - f.UsageTypeID = strings.TrimSpace(r.PostForm.Get("usage_type_id")) + f.ItemType = strings.TrimSpace(r.PostForm.Get("type_id")) + f.UsageType = usage.Parse(r.PostForm.Get("usage_type_id")) f.Name = strings.TrimSpace(r.PostForm.Get("equipment_name")) f.CategoryID = strings.TrimSpace(r.PostForm.Get("category_id")) f.CategoryName = strings.TrimSpace(r.PostForm.Get("category_name")) @@ -349,8 +327,8 @@ func ParseNew(r *http.Request) (NewForm, error) { f.LocationID = strings.TrimSpace(r.PostForm.Get("location_id")) f.LocationName = strings.TrimSpace(r.PostForm.Get("location_name")) f.Count = ParseQuantity(r.PostForm.Get("count")) - f.PurchasePrice = strings.TrimSpace(r.PostForm.Get("purchase_price")) - f.RentalPrice = strings.TrimSpace(r.PostForm.Get("rental_price")) + f.PurchasePrice = units.ParseCents(r.PostForm.Get("purchase_price")) + f.RentalPrice = units.ParseCents(r.PostForm.Get("rental_price")) f.Notes = strings.TrimSpace(r.PostForm.Get("notes")) file, header, err := r.FormFile("image") if err == nil { @@ -362,38 +340,14 @@ func ParseNew(r *http.Request) (NewForm, error) { // Validate checks NewForm fields and returns true when all checks pass. func (f *NewForm) Validate() bool { - f.Check(f.TypeID == "bulk" || f.TypeID == "serialized" || f.TypeID == "kit", "type_id", "Must be bulk, serialized, or kit") - f.Check(f.UsageTypeID == "rental" || f.UsageTypeID == "sale", "usage_type_id", "Must be rental or sale") + f.Check(f.ItemType == "bulk" || f.ItemType == "serialized" || f.ItemType == "kit", "type_id", "Must be bulk, serialized, or kit") + f.Check(f.UsageType != 0, "usage_type_id", "Must be rental or sale") f.Check(validator.NotBlank(f.Name), "name", "This field cannot be blank") f.Check(validator.MaxChars(f.Name, 200), "name", "This field cannot exceed 200 characters") f.Check(f.Count >= 1, "count", "Must be at least 1") return f.Valid() } -// TrackingType resolves the tracking type: kit maps to Serialized, everything else is literal. -func (f *NewForm) TrackingType() TrackingType { - if f.TypeID == "bulk" { - return Bulk - } - return Serialized -} - -// EquipmentType resolves the equipment type: kit maps to Kit, everything else to Standard. -func (f *NewForm) EquipmentType() Type { - if f.TypeID == "kit" { - return Kit - } - return Standard -} - -// ToPricing converts the form's parsed values into a Pricing sub-struct. -func (f *NewForm) ToPricing() Pricing { - return Pricing{ - PurchasePrice: ParseCents(f.PurchasePrice), - RentalPrice: ParseCents(f.RentalPrice), - } -} - // ContentForm holds parsed input and validation state for assigning content to an equipment item. type ContentForm struct { MemberName string diff --git a/internal/equipment/helpers.go b/internal/equipment/helpers.go index c04ca13..df62e9c 100644 --- a/internal/equipment/helpers.go +++ b/internal/equipment/helpers.go @@ -17,20 +17,11 @@ package equipment import ( - "math" "strconv" "strings" "time" ) -// parseCheckbox returns 1 if s is non-empty (checkbox was checked), 0 otherwise. -func parseCheckbox(s string) int64 { - if strings.TrimSpace(s) != "" { - return 1 - } - return 0 -} - // ParseQuantity parses a whole-number string. Returns 0 when blank or unparseable. func ParseQuantity(s string) int64 { s = strings.TrimSpace(s) @@ -44,104 +35,6 @@ func ParseQuantity(s string) int64 { return n } -// ParseCents parses a decimal price string and returns a pointer. Returns nil when blank or invalid. -func ParseCents(s string) *Cents { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - v := Cents(math.Round(f * 100)) - return &v -} - -// ParseGrams parses a kg decimal string and returns a pointer. Returns nil when blank or invalid. -func ParseGrams(s string) *Grams { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - v := Grams(math.Round(f * 1000)) - return &v -} - -// ParseMillimeters parses a cm decimal string and returns a pointer. Returns nil when blank or invalid. -func ParseMillimeters(s string) *Millimeters { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - v := Millimeters(math.Round(f * 10)) - return &v -} - -// ParseMilliwatts parses a W decimal string and returns a pointer. Returns nil when blank or invalid. -func ParseMilliwatts(s string) *Milliwatts { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - v := Milliwatts(math.Round(f * 1000)) - return &v -} - -// ParseMilliamps parses an A decimal string and returns a pointer. Returns nil when blank or invalid. -func ParseMilliamps(s string) *Milliamps { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - v := Milliamps(math.Round(f * 1000)) - return &v -} - -// ParseVolts parses a V decimal string and returns a pointer. Returns nil when blank or invalid. -func ParseVolts(s string) *Millivolts { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - f, err := strconv.ParseFloat(s, 64) - if err != nil { - return nil - } - v := Millivolts(math.Round(f * 1000)) - return &v -} - -// ParseWireGauge parses a whole-number mm²×100 string and returns a pointer. Returns nil when blank or invalid. -func ParseWireGauge(s string) *WireGauge { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - n, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return nil - } - v := WireGauge(n) - return &v -} - // ParseDate parses a YYYY-MM-DD date string and returns a Unix timestamp pointer. Returns nil when blank. func ParseDate(s string) *int64 { s = strings.TrimSpace(s) diff --git a/internal/equipment/locations/model.go b/internal/equipment/locations/model.go deleted file mode 100644 index c81862f..0000000 --- a/internal/equipment/locations/model.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2026 Tobias Gleiter -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Package locations handles location routes, business logic, and data access. -package locations - -import "errors" - -var ( - // ErrNotFound is returned when a location does not exist. - ErrNotFound = errors.New("location not found") - // ErrConflict is returned when a unique constraint is violated. - ErrConflict = errors.New("location already exists") - // ErrLimitExceeded is returned when an org's location limit is reached. - ErrLimitExceeded = errors.New("location limit exceeded") -) - -// Location represents a storage location belonging to an org. -type Location struct { - ID string - ParentWarehouseLocationID *string - OrgID string - Name string -} - -// CreateLocation holds the data required to create a new location. -type CreateLocation struct { - ID string - ParentWarehouseLocationID *string - OrgID string - Name string -} - -// UpdateLocation holds the data required to update a location. -type UpdateLocation struct { - ID string - Name string -} diff --git a/internal/equipment/manufacturers/model.go b/internal/equipment/manufacturers/model.go deleted file mode 100644 index 20291a1..0000000 --- a/internal/equipment/manufacturers/model.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (C) 2026 Tobias Gleiter -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Package manufacturers handles manufacturer routes, business logic, and data access. -package manufacturers - -import "errors" - -var ( - // ErrNotFound is returned when a manufacturer does not exist. - ErrNotFound = errors.New("manufacturer not found") - // ErrConflict is returned when a unique constraint is violated. - ErrConflict = errors.New("manufacturer already exists") - // ErrLimitExceeded is returned when an org's manufacturer limit is reached. - ErrLimitExceeded = errors.New("manufacturer limit exceeded") - // ErrInUse is returned when a manufacturer cannot be deleted due to assigned inventory items. - ErrInUse = errors.New("manufacturer is in use and cannot be deleted") -) - -// Manufacturer represents a manufacturer belonging to an org. -type Manufacturer struct { - ID string - OrgID string - Name string - UpdatedAt int64 - CreatedAt int64 -} - -// CreateManufacturer holds the data required to create a new manufacturer. -type CreateManufacturer struct { - ID string - OrgID string - Name string -} - -// UpdateManufacturer holds the data required to update a manufacturer. -type UpdateManufacturer struct { - ID string - Name string -} diff --git a/internal/equipment/mapping.go b/internal/equipment/mapping.go deleted file mode 100644 index d6797d6..0000000 --- a/internal/equipment/mapping.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (C) 2026 Tobias Gleiter -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Package equipment provides equipment functionality. -package equipment - -import ( - "github.com/bit8bytes/gearberg/internal/database" - genequip "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipment" -) - -func pricingFromListRow(row genequip.ListRow) Pricing { - return Pricing{ - PurchasePrice: database.NullAs[Cents](row.ResalePrice), - RentalPrice: database.NullAs[Cents](row.RentalPrice), - } -} - -func propertiesFromCreateRow(row genequip.CreateRow) Properties { - return Properties{ - Weight: database.NullAs[Grams](row.WeightG), - Width: database.NullAs[Millimeters](row.WidthMm), - Height: database.NullAs[Millimeters](row.HeightMm), - Depth: database.NullAs[Millimeters](row.DepthMm), - Power: database.NullAs[Milliwatts](row.PowerMw), - Current: database.NullAs[Milliamps](row.CurrentMa), - Voltage: database.NullAs[Millivolts](row.VoltageMv), - WireGauge: database.NullAs[WireGauge](row.WireGaugeMm2X100), - } -} - -func pricingFromCreateRow(row genequip.CreateRow) Pricing { - return Pricing{ - PurchasePrice: database.NullAs[Cents](row.ResalePrice), - RentalPrice: database.NullAs[Cents](row.RentalPrice), - } -} - -func propertiesFromGetByIDRow(row genequip.GetByIDRow) Properties { - return Properties{ - Weight: database.NullAs[Grams](row.WeightG), - Width: database.NullAs[Millimeters](row.WidthMm), - Height: database.NullAs[Millimeters](row.HeightMm), - Depth: database.NullAs[Millimeters](row.DepthMm), - Power: database.NullAs[Milliwatts](row.PowerMw), - Current: database.NullAs[Milliamps](row.CurrentMa), - Voltage: database.NullAs[Millivolts](row.VoltageMv), - WireGauge: database.NullAs[WireGauge](row.WireGaugeMm2X100), - } -} - -func pricingFromGetByIDRow(row genequip.GetByIDRow) Pricing { - return Pricing{ - PurchasePrice: database.NullAs[Cents](row.ResalePrice), - RentalPrice: database.NullAs[Cents](row.RentalPrice), - } -} - -func propertiesFromListRow(row genequip.ListRow) Properties { - return Properties{ - Weight: database.NullAs[Grams](row.WeightG), - Width: database.NullAs[Millimeters](row.WidthMm), - Height: database.NullAs[Millimeters](row.HeightMm), - Depth: database.NullAs[Millimeters](row.DepthMm), - Power: database.NullAs[Milliwatts](row.PowerMw), - Current: database.NullAs[Milliamps](row.CurrentMa), - Voltage: database.NullAs[Millivolts](row.VoltageMv), - WireGauge: database.NullAs[WireGauge](row.WireGaugeMm2X100), - } -} diff --git a/internal/equipment/model.go b/internal/equipment/model.go index 1a2eb26..becf437 100644 --- a/internal/equipment/model.go +++ b/internal/equipment/model.go @@ -21,6 +21,10 @@ import ( "fmt" "strings" "time" + + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipment/usage" + "github.com/bit8bytes/gearberg/internal/units" ) var ( @@ -44,19 +48,16 @@ var ( // Base holds fields shared between bulk and serialized creation. type Base struct { - OrgID string - EquipmentType Type - UsageTypeID int64 - Name string - CategoryID string - CategoryName string - ManufacturerID string - ManufacturerName string - LocationID string - LocationName string - Notes string - Pricing Pricing - Properties Properties + OrgID string + EquipmentType Type + UsageTypeID int64 + Name string + CategoryID string + ManufacturerID string + LocationID string + Notes string + Pricing Pricing + Properties Properties } // CreateEquipment holds the data required to create a new inventory item of any type. @@ -64,14 +65,15 @@ type Base struct { // how many units are generated. type CreateEquipment struct { Base - TrackingType TrackingType - TotalStock int64 - UnitCount int64 + ItemType string // "bulk", "serialized", or "kit" — maps to both equipment_type_id and tracking_type_id + TotalStock int64 + UnitCount int64 } // CreateBulkEquipment holds the data required to create a bulk inventory item. type CreateBulkEquipment struct { - ID string + ID string + BulkItemID string Base TotalStock int64 } @@ -91,7 +93,7 @@ type CreateUnit struct { SerialNumber string ManufacturerSerialNumber string Remark string - PurchasePrice *Cents + PurchasePrice *units.Cents PurchasedAt *int64 NextInspectionAt *int64 IsActive bool @@ -107,18 +109,15 @@ type AddUnit struct { // UpdateEquipmentDetails holds the data required to update the details tab fields. type UpdateEquipmentDetails struct { - ID string - OrgID string - Type TrackingType - Name string - CategoryID string - CategoryName string - ManufacturerID string - ManufacturerName string - LocationID string - LocationName string - Notes string - TotalStock int64 // TotalStock is only applied for bulk inventory items. + ID string + OrgID string + Type tracking.Type + Name string + CategoryID string + ManufacturerID string + LocationID string + Notes string + TotalStock int64 // TotalStock is only applied for bulk inventory items. } // UpdateEquipmentPricing holds the data required to update the pricing tab fields. @@ -140,7 +139,7 @@ type UpdateUnit struct { IsActive int64 ManufacturerSerialNumber string Remark string - PurchasePrice *Cents + PurchasePrice *units.Cents PurchasedAt *int64 NextInspectionAt *int64 } @@ -153,119 +152,29 @@ type AssignContent struct { Quantity int64 } -// RemoveContent holds the data required to remove a content entry. -type RemoveContent struct { - ID string -} - -// ArchiveEquipment holds the data required to archive or unarchive an equipment item. -type ArchiveEquipment struct { - ID string - IsArchived bool -} - // SetImage links or unlinks a storage object from an inventory item. type SetImage struct { ID string StorageObjectID *string } -// Cents is a monetary amount in the smallest currency unit (e.g. 1999 = €19.99). -type Cents int64 - -// ToDecimal formats the amount as a decimal string for form inputs (e.g. 1999 → "19.99"). -// Returns "" when nil. -func (c *Cents) ToDecimal() string { - if c == nil { - return "" - } - return fmt.Sprintf("%.2f", float64(*c)/100) -} - -// Grams is a weight stored in grams; users enter and see kilograms. -type Grams int64 - -// ToKG returns the weight in kg as a string for form inputs (e.g. 1500 → "1.5"). Returns "" when nil. -func (g *Grams) ToKG() string { - if g == nil { - return "" - } - return fmt.Sprintf("%g", float64(*g)/1000) -} - -// Millimeters is a length stored in millimetres; users enter and see centimetres. -type Millimeters int64 - -// ToCM returns the length in cm as a string for form inputs (e.g. 100 → "10"). Returns "" when nil. -func (m *Millimeters) ToCM() string { - if m == nil { - return "" - } - return fmt.Sprintf("%g", float64(*m)/10) -} - -// Milliwatts is power stored in milliwatts; users enter and see watts. -type Milliwatts int64 - -// ToW returns the power in W as a string for form inputs (e.g. 1500 → "1.5"). Returns "" when nil. -func (m *Milliwatts) ToW() string { - if m == nil { - return "" - } - return fmt.Sprintf("%g", float64(*m)/1000) -} - -// Milliamps is current stored in milliamps; users enter and see amps. -type Milliamps int64 - -// ToA returns the current in A as a string for form inputs (e.g. 500 → "0.5"). Returns "" when nil. -func (m *Milliamps) ToA() string { - if m == nil { - return "" - } - return fmt.Sprintf("%g", float64(*m)/1000) -} - -// Millivolts is voltage stored and displayed as whole volts. -type Millivolts int64 - -// ToV returns the voltage as a string for form inputs. Returns "" when nil. -func (v *Millivolts) ToV() string { - if v == nil { - return "" - } - return fmt.Sprintf("%g", float64(*v)/1000) -} - -// WireGauge stores wire cross-section area as mm²×100 (e.g. 150 = 1.5 mm²); displayed as-is. -type WireGauge int64 - -// String returns the raw mm²×100 value as a string for form inputs. Returns "" when nil. -func (w *WireGauge) String() string { - if w == nil { - return "" - } - return fmt.Sprintf("%d", *w) -} - // Properties holds the physical specification fields shown on the Properties tab. -// Adding a new physical property field only requires updating this struct, -// the mapper functions in mapping.go, and PropertiesForm in form.go. +// Adding a new physical property field only requires updating this struct and PropertiesForm in form.go. type Properties struct { - Weight *Grams - Width *Millimeters - Height *Millimeters - Depth *Millimeters - Power *Milliwatts - Current *Milliamps - Voltage *Millivolts - WireGauge *WireGauge + Weight *units.Grams + Width *units.Millimeters + Height *units.Millimeters + Depth *units.Millimeters + Power *units.Milliwatts + Current *units.Milliamps + Voltage *units.Millivolts + WireGauge *units.WireGauge } // Pricing holds the price fields shown on the Pricing tab. type Pricing struct { - PurchasePrice *Cents - RentalPrice *Cents + PurchasePrice *units.Cents + RentalPrice *units.Cents } // Equipment represents a single inventory item. @@ -273,8 +182,8 @@ type Equipment struct { ID string OrgID string Type Type - TrackingType TrackingType - UsageType UsageType + TrackingType tracking.Type + UsageType usage.Type Name string CategoryID string CategoryName string @@ -300,7 +209,7 @@ type ContentItem struct { EquipmentID string MemberID string MemberName string - MemberType TrackingType + MemberType tracking.Type Quantity int64 } @@ -318,7 +227,7 @@ type Unit struct { SerialNumber string ManufacturerSerialNumber string Remark string - PurchasePrice *Cents + PurchasePrice *units.Cents PurchasedAt *int64 NextInspectionAt *int64 CreatedAt int64 diff --git a/internal/equipment/repository.go b/internal/equipment/repository.go index 125624f..477cba6 100644 --- a/internal/equipment/repository.go +++ b/internal/equipment/repository.go @@ -21,15 +21,16 @@ import ( "database/sql" "errors" "fmt" - "math" "github.com/bit8bytes/gearberg/internal/database" genequip "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipment" genbulk "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipmentbulkitems" gencontent "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipmentcombinationitems" genserialized "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipmentserializeditems" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipment/usage" "github.com/bit8bytes/gearberg/internal/pagination" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/units" ) // Repository provides data access for inventory items. @@ -90,9 +91,9 @@ func listRowsToEquipment(rows []genequip.ListRow) ([]Equipment, int, error) { items = append(items, Equipment{ ID: row.ID, OrgID: row.OrgID, - Type: TypeFromString(row.EquipmentTypeName), - TrackingType: TrackingType(row.TrackingTypeID.Int64), - UsageType: UsageType(row.UsageTypeID), + Type: Parse(row.EquipmentTypeName), + TrackingType: tracking.Type(row.TrackingTypeID.Int64), + UsageType: usage.Type(row.UsageTypeID), Name: row.Name, CategoryID: database.String(row.CategoryID), CategoryName: row.CategoryName, @@ -103,17 +104,29 @@ func listRowsToEquipment(rows []genequip.ListRow) ([]Equipment, int, error) { TotalStock: row.TotalStock, IsArchived: row.IsArchived == 1, Notes: database.String(row.Notes), - Pricing: pricingFromListRow(row), - Properties: propertiesFromListRow(row), - UpdatedAt: row.UpdatedAt, - CreatedAt: row.CreatedAt, + Pricing: Pricing{ + PurchasePrice: row.ResalePrice, + RentalPrice: row.RentalPrice, + }, + Properties: Properties{ + Weight: row.WeightG, + Width: row.WidthMm, + Height: row.HeightMm, + Depth: row.DepthMm, + Power: row.PowerMw, + Current: row.CurrentMa, + Voltage: row.VoltageMv, + WireGauge: row.WireGaugeMm2X100, + }, + UpdatedAt: row.UpdatedAt, + CreatedAt: row.CreatedAt, }) } return items, int(totalRecords), nil } -// GetByID returns the inventory item with id, or ErrNotFound when it does not exist. -func (r *Repository) GetByID(ctx context.Context, id string) (*Equipment, error) { +// Get returns the inventory item with id, or ErrNotFound when it does not exist. +func (r *Repository) Get(ctx context.Context, id string) (*Equipment, error) { row, err := r.equipment.GetByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -124,9 +137,9 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*Equipment, error) m := Equipment{ ID: row.ID, OrgID: row.OrgID, - Type: TypeFromString(row.EquipmentTypeName), - TrackingType: TrackingType(row.TrackingTypeID.Int64), - UsageType: UsageType(row.UsageTypeID), + Type: Parse(row.EquipmentTypeName), + TrackingType: tracking.Type(row.TrackingTypeID.Int64), + UsageType: usage.Type(row.UsageTypeID), Name: row.Name, CategoryID: database.String(row.CategoryID), ManufacturerID: database.String(row.ManufacturerID), @@ -137,10 +150,22 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*Equipment, error) ContentCount: row.ContentCount, IsArchived: row.IsArchived == 1, Notes: database.String(row.Notes), - Pricing: pricingFromGetByIDRow(row), - Properties: propertiesFromGetByIDRow(row), - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + Pricing: Pricing{ + PurchasePrice: row.ResalePrice, + RentalPrice: row.RentalPrice, + }, + Properties: Properties{ + Weight: row.WeightG, + Width: row.WidthMm, + Height: row.HeightMm, + Depth: row.DepthMm, + Power: row.PowerMw, + Current: row.CurrentMa, + Voltage: row.VoltageMv, + WireGauge: row.WireGaugeMm2X100, + }, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } return &m, nil } @@ -163,24 +188,24 @@ func (r *Repository) createBulkWith(ctx context.Context, eqQ *genequip.Queries, ID: c.ID, OrgID: c.OrgID, EquipmentTypeID: c.EquipmentType.ID(), - TrackingTypeID: sql.NullInt64{Int64: Bulk.ID(), Valid: true}, + TrackingTypeID: sql.NullInt64{Int64: tracking.Bulk.ID(), Valid: true}, CategoryID: database.NullString(database.StringOrNil(c.CategoryID)), ManufacturerID: database.NullString(database.StringOrNil(c.ManufacturerID)), UsageTypeID: c.UsageTypeID, LocationID: database.NullString(database.StringOrNil(c.LocationID)), Name: c.Name, IsArchived: 0, - RentalPrice: database.NullOf(c.Pricing.RentalPrice), - ResalePrice: database.NullOf(c.Pricing.PurchasePrice), + RentalPrice: c.Pricing.RentalPrice, + ResalePrice: c.Pricing.PurchasePrice, Notes: database.NullString(database.StringOrNil(c.Notes)), - WeightG: database.NullOf(c.Properties.Weight), - WidthMm: database.NullOf(c.Properties.Width), - HeightMm: database.NullOf(c.Properties.Height), - DepthMm: database.NullOf(c.Properties.Depth), - CurrentMa: database.NullOf(c.Properties.Current), - PowerMw: database.NullOf(c.Properties.Power), - VoltageMv: database.NullOf(c.Properties.Voltage), - WireGaugeMm2X100: database.NullOf(c.Properties.WireGauge), + WeightG: c.Properties.Weight, + WidthMm: c.Properties.Width, + HeightMm: c.Properties.Height, + DepthMm: c.Properties.Depth, + CurrentMa: c.Properties.Current, + PowerMw: c.Properties.Power, + VoltageMv: c.Properties.Voltage, + WireGaugeMm2X100: c.Properties.WireGauge, }) if err != nil { normalized := database.NormalizeError(err) @@ -190,7 +215,7 @@ func (r *Repository) createBulkWith(ctx context.Context, eqQ *genequip.Queries, return nil, fmt.Errorf("createBulkWith: %w", normalized) } if _, err := bulkQ.Create(ctx, genbulk.CreateParams{ - ID: ksuid.New().String(), + ID: c.BulkItemID, EquipmentID: row.ID, Quantity: c.TotalStock, }); err != nil { @@ -200,8 +225,8 @@ func (r *Repository) createBulkWith(ctx context.Context, eqQ *genequip.Queries, ID: row.ID, OrgID: row.OrgID, Type: c.EquipmentType, - TrackingType: TrackingType(row.TrackingTypeID.Int64), - UsageType: UsageType(row.UsageTypeID), + TrackingType: tracking.Type(row.TrackingTypeID.Int64), + UsageType: usage.Type(row.UsageTypeID), Name: row.Name, CategoryID: database.String(row.CategoryID), ManufacturerID: database.String(row.ManufacturerID), @@ -209,9 +234,21 @@ func (r *Repository) createBulkWith(ctx context.Context, eqQ *genequip.Queries, StorageObjectID: database.StringPtr(row.StorageObjectID), TotalStock: c.TotalStock, Notes: database.String(row.Notes), - Pricing: pricingFromCreateRow(row), - Properties: propertiesFromCreateRow(row), - CreatedAt: row.CreatedAt, + Pricing: Pricing{ + PurchasePrice: row.ResalePrice, + RentalPrice: row.RentalPrice, + }, + Properties: Properties{ + Weight: row.WeightG, + Width: row.WidthMm, + Height: row.HeightMm, + Depth: row.DepthMm, + Power: row.PowerMw, + Current: row.CurrentMa, + Voltage: row.VoltageMv, + WireGauge: row.WireGaugeMm2X100, + }, + CreatedAt: row.CreatedAt, } return &m, nil } @@ -233,24 +270,24 @@ func (r *Repository) CreateSerialized(ctx context.Context, tx *sql.Tx, c CreateS ID: c.ID, OrgID: c.OrgID, EquipmentTypeID: c.EquipmentType.ID(), - TrackingTypeID: sql.NullInt64{Int64: Serialized.ID(), Valid: true}, + TrackingTypeID: sql.NullInt64{Int64: tracking.Serialized.ID(), Valid: true}, CategoryID: database.NullString(database.StringOrNil(c.CategoryID)), ManufacturerID: database.NullString(database.StringOrNil(c.ManufacturerID)), UsageTypeID: c.UsageTypeID, LocationID: database.NullString(database.StringOrNil(c.LocationID)), Name: c.Name, IsArchived: 0, - RentalPrice: database.NullOf(c.Pricing.RentalPrice), - ResalePrice: database.NullOf(c.Pricing.PurchasePrice), + RentalPrice: c.Pricing.RentalPrice, + ResalePrice: c.Pricing.PurchasePrice, Notes: database.NullString(database.StringOrNil(c.Notes)), - WeightG: database.NullOf(c.Properties.Weight), - WidthMm: database.NullOf(c.Properties.Width), - HeightMm: database.NullOf(c.Properties.Height), - DepthMm: database.NullOf(c.Properties.Depth), - CurrentMa: database.NullOf(c.Properties.Current), - PowerMw: database.NullOf(c.Properties.Power), - VoltageMv: database.NullOf(c.Properties.Voltage), - WireGaugeMm2X100: database.NullOf(c.Properties.WireGauge), + WeightG: c.Properties.Weight, + WidthMm: c.Properties.Width, + HeightMm: c.Properties.Height, + DepthMm: c.Properties.Depth, + CurrentMa: c.Properties.Current, + PowerMw: c.Properties.Power, + VoltageMv: c.Properties.Voltage, + WireGaugeMm2X100: c.Properties.WireGauge, }) if err != nil { normalized := database.NormalizeError(err) @@ -281,8 +318,8 @@ func (r *Repository) CreateSerialized(ctx context.Context, tx *sql.Tx, c CreateS ID: row.ID, OrgID: row.OrgID, Type: c.EquipmentType, - TrackingType: TrackingType(row.TrackingTypeID.Int64), - UsageType: UsageType(row.UsageTypeID), + TrackingType: tracking.Type(row.TrackingTypeID.Int64), + UsageType: usage.Type(row.UsageTypeID), Name: row.Name, CategoryID: database.String(row.CategoryID), ManufacturerID: database.String(row.ManufacturerID), @@ -290,9 +327,21 @@ func (r *Repository) CreateSerialized(ctx context.Context, tx *sql.Tx, c CreateS StorageObjectID: database.StringPtr(row.StorageObjectID), TotalStock: int64(len(c.Units)), Notes: database.String(row.Notes), - Pricing: pricingFromCreateRow(row), - Properties: propertiesFromCreateRow(row), - CreatedAt: row.CreatedAt, + Pricing: Pricing{ + PurchasePrice: row.ResalePrice, + RentalPrice: row.RentalPrice, + }, + Properties: Properties{ + Weight: row.WeightG, + Width: row.WidthMm, + Height: row.HeightMm, + Depth: row.DepthMm, + Power: row.PowerMw, + Current: row.CurrentMa, + Voltage: row.VoltageMv, + WireGauge: row.WireGaugeMm2X100, + }, + CreatedAt: row.CreatedAt, } return &m, nil } @@ -349,8 +398,8 @@ func (r *Repository) UpdateDetailsBulk(ctx context.Context, u UpdateEquipmentDet func (r *Repository) UpdatePricing(ctx context.Context, u UpdateEquipmentPricing) error { if err := r.equipment.UpdatePricing(ctx, genequip.UpdatePricingParams{ ID: u.ID, - ResalePrice: database.NullOf(u.Pricing.PurchasePrice), - RentalPrice: database.NullOf(u.Pricing.RentalPrice), + ResalePrice: u.Pricing.PurchasePrice, + RentalPrice: u.Pricing.RentalPrice, }); err != nil { return fmt.Errorf("UpdatePricing: %w", database.NormalizeError(err)) } @@ -361,14 +410,14 @@ func (r *Repository) UpdatePricing(ctx context.Context, u UpdateEquipmentPricing func (r *Repository) UpdateProperties(ctx context.Context, u UpdateEquipmentProperties) error { if err := r.equipment.UpdateProperties(ctx, genequip.UpdatePropertiesParams{ ID: u.ID, - WeightG: database.NullOf(u.Properties.Weight), - WidthMm: database.NullOf(u.Properties.Width), - HeightMm: database.NullOf(u.Properties.Height), - DepthMm: database.NullOf(u.Properties.Depth), - VoltageMv: database.NullOf(u.Properties.Voltage), - CurrentMa: database.NullOf(u.Properties.Current), - PowerMw: database.NullOf(u.Properties.Power), - WireGaugeMm2X100: database.NullOf(u.Properties.WireGauge), + WeightG: u.Properties.Weight, + WidthMm: u.Properties.Width, + HeightMm: u.Properties.Height, + DepthMm: u.Properties.Depth, + VoltageMv: u.Properties.Voltage, + CurrentMa: u.Properties.Current, + PowerMw: u.Properties.Power, + WireGaugeMm2X100: u.Properties.WireGauge, }); err != nil { return fmt.Errorf("UpdateProperties: %w", database.NormalizeError(err)) } @@ -390,7 +439,7 @@ func (r *Repository) ListUnits(ctx context.Context, equipmentID string) ([]Unit, SerialNumber: row.SerialNumber, ManufacturerSerialNumber: database.String(row.ManufacturerSerial), Remark: database.String(row.Remark), - PurchasePrice: database.NullAs[Cents](row.PurchasePrice), + PurchasePrice: database.NullAs[units.Cents](row.PurchasePrice), PurchasedAt: database.Int64Ptr(row.PurchasedAt), NextInspectionAt: database.Int64Ptr(row.NextInspectionAt), CreatedAt: row.CreatedAt, @@ -400,35 +449,6 @@ func (r *Repository) ListUnits(ctx context.Context, equipmentID string) ([]Unit, return us, nil } -// ListAll returns all inventory items for orgID ordered by name, with no pagination. -// Passes -1 as the is_archived sentinel to skip the archived filter and return all items. -func (r *Repository) ListAll(ctx context.Context, orgID string) ([]Equipment, error) { - rows, err := r.equipment.List(ctx, genequip.ListParams{ - OrgID: orgID, - NameQuery: "", - Category: "", - IsArchived: -1, - PageOffset: 0, - PageLimit: math.MaxInt32, - }) - if err != nil { - return nil, fmt.Errorf("ListAll: %w", err) - } - items, _, err := listRowsToEquipment(rows) - return items, err -} - -// Archive sets the is_archived flag on an inventory item. -func (r *Repository) Archive(ctx context.Context, a ArchiveEquipment) error { - if err := r.equipment.UpdateArchived(ctx, genequip.UpdateArchivedParams{ - ID: a.ID, - IsArchived: database.Bool(a.IsArchived), - }); err != nil { - return fmt.Errorf("Archive: %w", database.NormalizeError(err)) - } - return nil -} - // Delete removes the inventory item. Returns ErrInUse when active rental line items reference it. func (r *Repository) Delete(ctx context.Context, id string) error { if err := r.equipment.Delete(ctx, id); err != nil { @@ -457,7 +477,7 @@ func (r *Repository) GetUnit(ctx context.Context, id string) (*Unit, error) { SerialNumber: row.SerialNumber, ManufacturerSerialNumber: database.String(row.ManufacturerSerial), Remark: database.String(row.Remark), - PurchasePrice: database.NullAs[Cents](row.PurchasePrice), + PurchasePrice: database.NullAs[units.Cents](row.PurchasePrice), PurchasedAt: database.Int64Ptr(row.PurchasedAt), NextInspectionAt: database.Int64Ptr(row.NextInspectionAt), CreatedAt: row.CreatedAt, @@ -547,7 +567,7 @@ func (r *Repository) ListContent(ctx context.Context, equipmentID string) ([]Con EquipmentID: row.EquipmentID, MemberID: row.MemberEquipmentID, MemberName: row.MemberName, - MemberType: TrackingType(row.MemberTrackingTypeID.Int64), + MemberType: tracking.Type(row.MemberTrackingTypeID.Int64), Quantity: row.Quantity, }) } diff --git a/internal/equipment/service.go b/internal/equipment/service.go index 95169bb..fbb314c 100644 --- a/internal/equipment/service.go +++ b/internal/equipment/service.go @@ -22,78 +22,51 @@ import ( "fmt" "math" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" "github.com/bit8bytes/gearberg/internal/pagination" "github.com/bit8bytes/gearberg/internal/serial" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/uid" ) -// Manufacturers upserts manufacturers by name. -type Manufacturers interface { - Upsert(ctx context.Context, orgID, name string) (string, error) -} - -// Locations upserts locations by name. -type Locations interface { - Upsert(ctx context.Context, orgID, name string) (string, error) -} - -// Categories upserts categories by name. -type Categories interface { - Upsert(ctx context.Context, orgID, name string) (string, error) -} - // Service implements business logic for inventory. type Service struct { - db *sql.DB - repo *Repository - manufacturers Manufacturers - locations Locations - categories Categories + db *sql.DB + repo *Repository } // NewService returns a new Service. -func NewService(repo *Repository, db *sql.DB, cats Categories, mfrs Manufacturers, locs Locations) *Service { - return &Service{db: db, repo: repo, categories: cats, manufacturers: mfrs, locations: locs} +func NewService(repo *Repository, db *sql.DB) *Service { + return &Service{db: db, repo: repo} } // Create creates a new inventory item, dispatching to the correct path based on type. // Serialized creation runs inside a transaction managed by the service. -// resolveRefs upserts manufacturer and location by name when the corresponding -// ID is empty. It mutates the two ID pointers in place. -func (s *Service) resolveRefs(ctx context.Context, orgID string, manufacturerID *string, manufacturerName string, locationID *string, locationName string, categoryID *string, categoryName string) error { - if *manufacturerID == "" && manufacturerName != "" { - id, err := s.manufacturers.Upsert(ctx, orgID, manufacturerName) - if err != nil { - return fmt.Errorf("resolveRefs: manufacturer: %w", err) - } - *manufacturerID = id - } - if *locationID == "" && locationName != "" { - id, err := s.locations.Upsert(ctx, orgID, locationName) - if err != nil { - return fmt.Errorf("resolveRefs: location: %w", err) - } - *locationID = id - } - if *categoryID == "" && categoryName != "" { - id, err := s.categories.Upsert(ctx, orgID, categoryName) - if err != nil { - return fmt.Errorf("resolveRefs: category: %w", err) - } - *categoryID = id - } - return nil -} - -// Create creates a new equipment item with its associated units. func (s *Service) Create(ctx context.Context, c CreateEquipment) (*Equipment, error) { - if err := s.resolveRefs(ctx, c.OrgID, &c.ManufacturerID, c.ManufacturerName, &c.LocationID, c.LocationName, &c.CategoryID, c.CategoryName); err != nil { - return nil, fmt.Errorf("Create: %w", err) + var trackingType tracking.Type + switch c.ItemType { + case tracking.Bulk.String(): + trackingType = tracking.Bulk + c.EquipmentType = StandardType + case tracking.Serialized.String(): + trackingType = tracking.Serialized + c.EquipmentType = StandardType + case KitType.String(): + trackingType = tracking.Serialized + c.EquipmentType = KitType + default: + return nil, fmt.Errorf("Create: unknown item type %q", c.ItemType) } - itemID := ksuid.New().String() + + itemID := uid.New() units := make([]CreateUnit, c.UnitCount) for i := range units { - units[i] = CreateUnit{ID: ksuid.New().String(), OrgID: c.OrgID, EquipmentID: itemID, SerialNumber: serial.New(), IsActive: true} + units[i] = CreateUnit{ + ID: uid.New(), + OrgID: c.OrgID, + EquipmentID: itemID, + SerialNumber: serial.New(), + IsActive: true, + } } tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -104,13 +77,20 @@ func (s *Service) Create(ctx context.Context, c CreateEquipment) (*Equipment, er // Both types go through the same tx path; Bulk doesn't need atomicity but a // single code path is easier to maintain than two separate non-tx branches. var item *Equipment - switch c.TrackingType { - case Bulk: - item, err = s.repo.CreateBulk(ctx, tx, CreateBulkEquipment{ID: itemID, Base: c.Base, TotalStock: c.TotalStock}) - case Serialized: - item, err = s.repo.CreateSerialized(ctx, tx, CreateSerializedEquipment{ID: itemID, Base: c.Base, Units: units}) - default: - return nil, fmt.Errorf("Create: unknown equipment type %q", c.TrackingType) + switch trackingType { + case tracking.Bulk: + item, err = s.repo.CreateBulk(ctx, tx, CreateBulkEquipment{ + ID: itemID, + BulkItemID: uid.New(), + Base: c.Base, + TotalStock: c.TotalStock, + }) + case tracking.Serialized: + item, err = s.repo.CreateSerialized(ctx, tx, CreateSerializedEquipment{ + ID: itemID, + Base: c.Base, + Units: units, + }) } if err != nil { return nil, fmt.Errorf("Create: %w", err) @@ -121,35 +101,23 @@ func (s *Service) Create(ctx context.Context, c CreateEquipment) (*Equipment, er return item, nil } -// GetFiltered returns a page of inventory items for orgID, filtered by query, -// category, and archive status, sorted and paginated according to f. -// sortBy may be "code" to order by unit code; otherwise orders by name. -func (s *Service) GetFiltered(ctx context.Context, orgID, query, category string, f pagination.Filters) ([]Equipment, pagination.Metadata, error) { - items, total, err := s.repo.List(ctx, orgID, query, category, false, f) - if err != nil { - return nil, pagination.Metadata{}, fmt.Errorf("GetFiltered: %w", err) - } - meta := pagination.CalculateMetadata(total, f.Page, f.PageSize) - return items, meta, nil -} - -// ListAll returns all inventory items for orgID with no pagination, ordered by name. -func (s *Service) ListAll(ctx context.Context, orgID string) ([]Equipment, error) { - items, err := s.repo.ListAll(ctx, orgID) - if err != nil { - return nil, fmt.Errorf("ListAll: %w", err) - } - return items, nil +// ListParams holds the parameters for listing equipment. +type ListParams struct { + OrgID string + Query string + Category string + Filters pagination.Filters } -// ListAllFiltered returns all items for orgID that match the given filters, with no pagination. -func (s *Service) ListAllFiltered(ctx context.Context, orgID, query, category string, showArchived bool) ([]Equipment, error) { - f := pagination.Filters{Page: 1, PageSize: math.MaxInt32} - items, _, err := s.repo.List(ctx, orgID, query, category, showArchived, f) +// List returns equipment for orgID filtered and paginated according to p. +// Returns all items when p.Filters.PageSize is math.MaxInt32. +func (s *Service) List(ctx context.Context, p ListParams) ([]Equipment, pagination.Metadata, error) { + items, total, err := s.repo.List(ctx, p.OrgID, p.Query, p.Category, false, p.Filters) if err != nil { - return nil, fmt.Errorf("ListAllFiltered: %w", err) + return nil, pagination.Metadata{}, fmt.Errorf("List: %w", err) } - return items, nil + meta := pagination.CalculateMetadata(total, p.Filters.Page, p.Filters.PageSize) + return items, meta, nil } // findByName returns the first equipment item in items whose name matches name, @@ -166,7 +134,7 @@ func findByName(items []Equipment, name string) *Equipment { // AssignContentByName resolves the member by name within the org then assigns it as content. // Returns ErrNotFound when no equipment matches name. func (s *Service) AssignContentByName(ctx context.Context, orgID, memberName string, a AssignContent) (*ContentItem, error) { - all, err := s.repo.ListAll(ctx, orgID) + all, _, err := s.repo.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32}) if err != nil { return nil, fmt.Errorf("AssignContentByName: %w", err) } @@ -182,40 +150,19 @@ func (s *Service) AssignContentByName(ctx context.Context, orgID, memberName str return item, nil } -// GetByID returns the inventory item with id. -func (s *Service) GetByID(ctx context.Context, id string) (*Equipment, error) { - item, err := s.repo.GetByID(ctx, id) +// Get returns the inventory item with id. +func (s *Service) Get(ctx context.Context, id string) (*Equipment, error) { + item, err := s.repo.Get(ctx, id) if err != nil { return nil, fmt.Errorf("GetByID: %w", err) } return item, nil } -// CreateBulkTx creates a new bulk inventory item within an existing transaction. -func (s *Service) CreateBulkTx(ctx context.Context, tx *sql.Tx, c CreateBulkEquipment) (*Equipment, error) { - item, err := s.repo.CreateBulk(ctx, tx, c) - if err != nil { - return nil, fmt.Errorf("CreateBulkTx: %w", err) - } - return item, nil -} - -// CreateSerialized creates a new serialized inventory item with all its units in a single transaction. -func (s *Service) CreateSerialized(ctx context.Context, tx *sql.Tx, c CreateSerializedEquipment) (*Equipment, error) { - item, err := s.repo.CreateSerialized(ctx, tx, c) - if err != nil { - return nil, fmt.Errorf("CreateSerialized: %w", err) - } - return item, nil -} - // UpdateDetails updates the details-tab fields. For bulk items it also updates // the stock quantity; for serialized items only the inventory row is written. func (s *Service) UpdateDetails(ctx context.Context, u UpdateEquipmentDetails) error { - if err := s.resolveRefs(ctx, u.OrgID, &u.ManufacturerID, u.ManufacturerName, &u.LocationID, u.LocationName, &u.CategoryID, u.CategoryName); err != nil { - return fmt.Errorf("UpdateDetails: %w", err) - } - if u.Type == Bulk { + if u.Type == tracking.Bulk { if err := s.repo.UpdateDetailsBulk(ctx, u); err != nil { return fmt.Errorf("UpdateDetails: %w", err) } @@ -259,14 +206,6 @@ func (s *Service) Delete(ctx context.Context, id string) error { return nil } -// Archive sets the archived status of an equipment item. -func (s *Service) Archive(ctx context.Context, a ArchiveEquipment) error { - if err := s.repo.Archive(ctx, a); err != nil { - return fmt.Errorf("Archive: %w", err) - } - return nil -} - // ListUnits returns all equipment items for the given inventory item, ordered by serial_number. func (s *Service) ListUnits(ctx context.Context, inventoryID string) ([]Unit, error) { units, err := s.repo.ListUnits(ctx, inventoryID) @@ -288,14 +227,14 @@ func (s *Service) GetUnit(ctx context.Context, id string) (*Unit, error) { // AddUnit adds a new unit to the serialized inventory item. // Returns ErrNotSerializedUnit when the equipment is not serialized. func (s *Service) AddUnit(ctx context.Context, a AddUnit) (*Unit, error) { - item, err := s.repo.GetByID(ctx, a.EquipmentID) + item, err := s.repo.Get(ctx, a.EquipmentID) if err != nil { return nil, fmt.Errorf("AddUnit: %w", err) } - if item.TrackingType != Serialized { + if item.TrackingType != tracking.Serialized { return nil, fmt.Errorf("AddUnit: %w", ErrNotSerializedUnit) } - a.ID = ksuid.New().String() + a.ID = uid.New() a.SerialNumber = serial.New() u, err := s.repo.AddUnit(ctx, a) if err != nil { @@ -331,11 +270,11 @@ func (s *Service) DeleteUnit(ctx context.Context, unitID string) error { // GetUnitsContainer fetches the equipment item and verifies it supports the units tab. // Returns ErrNotFound when the item does not exist, ErrNoUnitsTab when it is bulk equipment. func (s *Service) GetUnitsContainer(ctx context.Context, id string) (*Equipment, error) { - item, err := s.repo.GetByID(ctx, id) + item, err := s.repo.Get(ctx, id) if err != nil { return nil, fmt.Errorf("GetUnitsContainer: %w", err) } - if item.TrackingType != Serialized { + if item.TrackingType != tracking.Serialized { return nil, fmt.Errorf("GetUnitsContainer: %w", ErrNoUnitsTab) } return item, nil @@ -344,11 +283,11 @@ func (s *Service) GetUnitsContainer(ctx context.Context, id string) (*Equipment, // GetContentContainer fetches the equipment item and verifies it supports the content tab. // Returns ErrNotFound when the item does not exist, ErrNoContentTab when it is not a container. func (s *Service) GetContentContainer(ctx context.Context, id string) (*Equipment, error) { - item, err := s.repo.GetByID(ctx, id) + item, err := s.repo.Get(ctx, id) if err != nil { return nil, fmt.Errorf("GetContentContainer: %w", err) } - if item.Type != Kit { + if item.Type != KitType { return nil, fmt.Errorf("GetContentContainer: %w", ErrNoContentTab) } return item, nil @@ -368,11 +307,11 @@ func (s *Service) ListContent(ctx context.Context, equipmentID string) ([]Conten // Returns ErrConflict when the member is already assigned. // Stock sufficiency is not enforced here; callers should surface warnings via ListContent. func (s *Service) AssignContent(ctx context.Context, a AssignContent, member Equipment) (*ContentItem, error) { - a.ID = ksuid.New().String() + a.ID = uid.New() if member.ID == a.EquipmentID { return nil, fmt.Errorf("AssignContent: %w: an item cannot contain itself", ErrInvalidContent) } - if member.Type == Kit { + if member.Type == KitType { return nil, fmt.Errorf("AssignContent: %w: cannot add a container as content", ErrInvalidContent) } item, err := s.repo.AssignContent(ctx, a) diff --git a/internal/equipment/trackingtypes.go b/internal/equipment/tracking/tracking.go similarity index 73% rename from internal/equipment/trackingtypes.go rename to internal/equipment/tracking/tracking.go index 81da56c..7cde0f0 100644 --- a/internal/equipment/trackingtypes.go +++ b/internal/equipment/tracking/tracking.go @@ -13,25 +13,25 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package equipment provides equipment functionality. -package equipment +// Package tracking defines how equipment inventory is tracked (bulk or serialized). +package tracking -// TrackingType represents an inventory type (Bulk or Serialized). +// Type represents an inventory type (Bulk or Serialized). // The integer value matches the id stored in the inventory_types table, // which is seeded at startup rather than via migrations. -type TrackingType int64 +type Type int64 // Equipment type identifiers seeded into the inventory_types table. const ( - Bulk TrackingType = 1 - Serialized TrackingType = 2 + Bulk Type = 1 + Serialized Type = 2 ) // ID returns the database id for the inventory type. -func (k TrackingType) ID() int64 { return int64(k) } +func (k Type) ID() int64 { return int64(k) } // String returns the name stored in the inventory_types table. -func (k TrackingType) String() string { +func (k Type) String() string { switch k { case Bulk: return "bulk" @@ -42,8 +42,8 @@ func (k TrackingType) String() string { } } -// TrackingTypeFromString returns the TrackingType matching name ("bulk" or "serialized"), or 0 when unknown. -func TrackingTypeFromString(name string) TrackingType { +// Parse returns the [Type] matching name ("bulk" or "serialized"), or 0 when unknown. +func Parse(name string) Type { switch name { case "bulk": return Bulk @@ -55,7 +55,7 @@ func TrackingTypeFromString(name string) TrackingType { } // Label returns the human-friendly label for the inventory type. -func (k TrackingType) Label() string { +func (k Type) Label() string { switch k { case Bulk: return "Bulk" diff --git a/internal/equipment/type.go b/internal/equipment/type.go index f8df5cd..59e0f90 100644 --- a/internal/equipment/type.go +++ b/internal/equipment/type.go @@ -23,8 +23,8 @@ type Type int64 // Equipment type identifiers seeded into the equipment_types table. const ( - Standard Type = 1 - Kit Type = 2 + StandardType Type = 1 + KitType Type = 2 ) // ID returns the database id for the equipment type. @@ -33,41 +33,41 @@ func (t Type) ID() int64 { return int64(t) } // String returns the name stored in the equipment_types table. func (t Type) String() string { switch t { - case Standard: + case StandardType: return "standard" - case Kit: + case KitType: return "kit" default: return "" } } -// TypeFromString returns the Type matching name, or 0 when unknown. -func TypeFromString(name string) Type { +// Parse returns the Type matching name, or 0 when unknown. +func Parse(name string) Type { switch name { case "standard": - return Standard + return StandardType case "kit": - return Kit + return KitType default: return 0 } } -// TypeFromStringOrDefault returns the Type matching name, or Standard when unknown. -func TypeFromStringOrDefault(name string) Type { - if t := TypeFromString(name); t != 0 { +// ParseOrDefault returns the Type matching name, or Standard when unknown. +func ParseOrDefault(name string) Type { + if t := Parse(name); t != 0 { return t } - return Standard + return StandardType } // Label returns the human-friendly label for the equipment type. func (t Type) Label() string { switch t { - case Standard: + case StandardType: return "Standard" - case Kit: + case KitType: return "Kit" default: return "" diff --git a/internal/equipment/usagetype.go b/internal/equipment/usage/usage.go similarity index 75% rename from internal/equipment/usagetype.go rename to internal/equipment/usage/usage.go index 7c91cc5..322dfb0 100644 --- a/internal/equipment/usagetype.go +++ b/internal/equipment/usage/usage.go @@ -13,24 +13,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package equipment provides equipment functionality. -package equipment +// Package usage defines equipment usage classifications (rental, sale, etc.). +package usage -// UsageType represents how an inventory item is used (rental or sale). +// Type represents how an inventory item is used (rental or sale). // The integer value matches the id stored in the usage_types table, // which is seeded at startup rather than via migrations. -type UsageType int64 +type Type int64 // Usage type identifiers seeded into the usage_types table. const ( - Rental UsageType = 1 + Rental Type = 1 ) // ID returns the database id for the usage type. -func (u UsageType) ID() int64 { return int64(u) } +func (u Type) ID() int64 { return int64(u) } -// ParseUsage returns the Usage matching name, or 0 when unknown. -func ParseUsage(name string) UsageType { +// Parse returns the Usage matching name, or 0 when unknown. +func Parse(name string) Type { switch name { case "rental": return Rental @@ -40,7 +40,7 @@ func ParseUsage(name string) UsageType { } // Label returns the human-friendly label for the usage type. -func (u UsageType) Label() string { +func (u Type) Label() string { switch u { case Rental: return "Rental" @@ -50,7 +50,7 @@ func (u UsageType) Label() string { } // String returns the name stored in the usage_types table. -func (u UsageType) String() string { +func (u Type) String() string { switch u { case Rental: return "rental" diff --git a/internal/equipment/imports/csv.go b/internal/equipmentimports/csv.go similarity index 98% rename from internal/equipment/imports/csv.go rename to internal/equipmentimports/csv.go index 545ba91..d0e4263 100644 --- a/internal/equipment/imports/csv.go +++ b/internal/equipmentimports/csv.go @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package imports provides imports functionality. -package imports +// Package equipmentimports provides imports functionality. +package equipmentimports import ( "bufio" diff --git a/internal/equipment/imports/csv_test.go b/internal/equipmentimports/csv_test.go similarity index 86% rename from internal/equipment/imports/csv_test.go rename to internal/equipmentimports/csv_test.go index ce9ad25..46578be 100644 --- a/internal/equipment/imports/csv_test.go +++ b/internal/equipmentimports/csv_test.go @@ -12,21 +12,21 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package imports_test +package equipmentimports_test import ( "bytes" "strings" "testing" - "github.com/bit8bytes/gearberg/internal/equipment/imports" + "github.com/bit8bytes/gearberg/internal/equipmentimports" ) // TestParseCSV_roundtrip verifies that every column in ExpectedHeaders maps to // the correct RawRow field. If ExpectedHeaders is reordered or readRows is not // updated in step, this test breaks before any real data is affected. func TestParseCSV_roundtrip(t *testing.T) { - want := imports.RawRow{ + want := equipmentimports.RawRow{ Name: "Shure SM58", TypeLabel: "Bulk", UsageTypeLabel: "Rental", @@ -55,7 +55,7 @@ func TestParseCSV_roundtrip(t *testing.T) { UnitRemark: "", } - header := strings.Join(imports.ExpectedHeaders, ",") + header := strings.Join(equipmentimports.ExpectedHeaders, ",") dataRow := strings.Join([]string{ want.Name, want.TypeLabel, @@ -86,7 +86,7 @@ func TestParseCSV_roundtrip(t *testing.T) { }, ",") csv := header + "\n" + dataRow + "\n" - rows, err := imports.ParseCSV(strings.NewReader(csv)) + rows, err := equipmentimports.ParseCSV(strings.NewReader(csv)) if err != nil { t.Fatalf("ParseCSV: %v", err) } @@ -131,7 +131,7 @@ func TestParseCSV_roundtrip(t *testing.T) { func TestParseCSV_wrongColumnCount(t *testing.T) { csv := "Name,Type\nFoo,Bar\n" - _, err := imports.ParseCSV(strings.NewReader(csv)) + _, err := equipmentimports.ParseCSV(strings.NewReader(csv)) if err == nil { t.Fatal("expected error for wrong column count, got nil") } @@ -139,19 +139,19 @@ func TestParseCSV_wrongColumnCount(t *testing.T) { func TestParseCSV_wrongColumnName(t *testing.T) { // Replace the first header with something unexpected. - headers := make([]string, len(imports.ExpectedHeaders)) - copy(headers, imports.ExpectedHeaders) + headers := make([]string, len(equipmentimports.ExpectedHeaders)) + copy(headers, equipmentimports.ExpectedHeaders) headers[0] = "ItemName" // was "Name" csv := strings.Join(headers, ",") + "\nFoo,Bulk,Rental,Audio,Shure,WH,15,99,,,,,,,,,,1\n" - _, err := imports.ParseCSV(strings.NewReader(csv)) + _, err := equipmentimports.ParseCSV(strings.NewReader(csv)) if err == nil { t.Fatal("expected error for wrong column name, got nil") } } func TestParseCSV_noDataRows(t *testing.T) { - csv := strings.Join(imports.ExpectedHeaders, ",") + "\n" - _, err := imports.ParseCSV(strings.NewReader(csv)) + csv := strings.Join(equipmentimports.ExpectedHeaders, ",") + "\n" + _, err := equipmentimports.ParseCSV(strings.NewReader(csv)) if err == nil { t.Fatal("expected error for empty data, got nil") } @@ -161,7 +161,7 @@ func TestParseCSV_noDataRows(t *testing.T) { // change to ExpectedHeaders that is not reflected in template.csv breaks the // build immediately rather than at runtime. func TestParseCSV_templateValid(t *testing.T) { - rows, err := imports.ParseCSV(bytes.NewReader(imports.TemplateCSV)) + rows, err := equipmentimports.ParseCSV(bytes.NewReader(equipmentimports.TemplateCSV)) if err != nil { t.Fatalf("TemplateCSV is not valid: %v", err) } @@ -173,8 +173,8 @@ func TestParseCSV_templateValid(t *testing.T) { // TestParseCSV_legacyHasContentHeader verifies that old exports using "Has Content" // instead of "Equipment Type" are accepted and their boolean values normalised. func TestParseCSV_legacyHasContentHeader(t *testing.T) { - headers := make([]string, len(imports.ExpectedHeaders)) - copy(headers, imports.ExpectedHeaders) + headers := make([]string, len(equipmentimports.ExpectedHeaders)) + copy(headers, equipmentimports.ExpectedHeaders) headers[18] = "Has Content" // old column name cases := []struct { @@ -185,7 +185,7 @@ func TestParseCSV_legacyHasContentHeader(t *testing.T) { {"FALSE", "Standard"}, {"1", "Kit"}, {"0", "Standard"}, - {"", ""}, // blank → left as-is, resolves to Standard via TypeFromStringOrDefault + {"", ""}, // blank → left as-is, resolves to Standard via ParseOrDefault {"Kit", "Kit"}, // already canonical } @@ -196,7 +196,7 @@ func TestParseCSV_legacyHasContentHeader(t *testing.T) { tc.hasContent, "", "", "", "", "", "", "", }, ",") csv := strings.Join(headers, ",") + "\n" + row + "\n" - rows, err := imports.ParseCSV(strings.NewReader(csv)) + rows, err := equipmentimports.ParseCSV(strings.NewReader(csv)) if err != nil { t.Fatalf("hasContent=%q: ParseCSV: %v", tc.hasContent, err) } @@ -207,9 +207,9 @@ func TestParseCSV_legacyHasContentHeader(t *testing.T) { } func TestParseCSV_stripsUTF8BOM(t *testing.T) { - header := strings.Join(imports.ExpectedHeaders, ",") + header := strings.Join(equipmentimports.ExpectedHeaders, ",") csv := "\xEF\xBB\xBF" + header + "\nShure SM58,Bulk,Rental,Audio,Shure,WH,15,99,,,,,,,,,,1,,,,,,,,\n" - rows, err := imports.ParseCSV(strings.NewReader(csv)) + rows, err := equipmentimports.ParseCSV(strings.NewReader(csv)) if err != nil { t.Fatalf("ParseCSV with BOM: %v", err) } diff --git a/internal/equipment/imports/model.go b/internal/equipmentimports/model.go similarity index 97% rename from internal/equipment/imports/model.go rename to internal/equipmentimports/model.go index ef41adf..b7e3d0c 100644 --- a/internal/equipment/imports/model.go +++ b/internal/equipmentimports/model.go @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package imports handles CSV import staging and commit for inventory items. -package imports +// Package equipmentimports handles CSV import staging and commit for inventory items. +package equipmentimports import _ "embed" diff --git a/internal/equipment/imports/repository.go b/internal/equipmentimports/repository.go similarity index 98% rename from internal/equipment/imports/repository.go rename to internal/equipmentimports/repository.go index 78bab76..6632bda 100644 --- a/internal/equipment/imports/repository.go +++ b/internal/equipmentimports/repository.go @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package imports provides imports functionality. -package imports +// Package equipmentimports provides imports functionality. +package equipmentimports import ( "context" diff --git a/internal/equipment/imports/roundtrip_test.go b/internal/equipmentimports/roundtrip_test.go similarity index 74% rename from internal/equipment/imports/roundtrip_test.go rename to internal/equipmentimports/roundtrip_test.go index 430f878..da495bf 100644 --- a/internal/equipment/imports/roundtrip_test.go +++ b/internal/equipmentimports/roundtrip_test.go @@ -12,7 +12,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package imports_test +package equipmentimports_test import ( "bytes" @@ -21,7 +21,10 @@ import ( "testing" "github.com/bit8bytes/gearberg/internal/equipment" - "github.com/bit8bytes/gearberg/internal/equipment/imports" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipment/usage" + "github.com/bit8bytes/gearberg/internal/equipmentimports" + "github.com/bit8bytes/gearberg/internal/units" ) // buildCSV writes ExpectedHeaders followed by the rows produced by RowsForItem, @@ -29,8 +32,8 @@ import ( func buildCSV(item equipment.Equipment, mfrName string, units []equipment.Unit) string { var buf bytes.Buffer cw := csv.NewWriter(&buf) - _ = cw.Write(imports.ExpectedHeaders) - for _, row := range imports.RowsForItem(item, mfrName, units) { + _ = cw.Write(equipmentimports.ExpectedHeaders) + for _, row := range equipmentimports.RowsForItem(item, mfrName, units) { _ = cw.Write(row) } cw.Flush() @@ -41,25 +44,25 @@ func TestRoundTrip_bulk(t *testing.T) { stock := int64(7) item := equipment.Equipment{ Name: "Shure SM58", - TrackingType: equipment.Bulk, - UsageType: equipment.Rental, + TrackingType: tracking.Bulk, + UsageType: usage.Rental, CategoryName: "Audio", LocationName: "Main Warehouse", TotalStock: stock, Notes: "Cardioid dynamic mic", Pricing: equipment.Pricing{ - RentalPrice: equipment.ParseCents("15.00"), - PurchasePrice: equipment.ParseCents("99.00"), + RentalPrice: units.ParseCents("15.00"), + PurchasePrice: units.ParseCents("99.00"), }, Properties: equipment.Properties{ - Weight: equipment.ParseGrams("0.298"), - Width: equipment.ParseMillimeters("4.7"), - Height: equipment.ParseMillimeters("4.7"), - Depth: equipment.ParseMillimeters("16.2"), + Weight: units.ParseGrams("0.298"), + Width: units.ParseMillimeters("4.7"), + Height: units.ParseMillimeters("4.7"), + Depth: units.ParseMillimeters("16.2"), }, } - rows, err := imports.ParseCSV(strings.NewReader(buildCSV(item, "Shure", nil))) + rows, err := equipmentimports.ParseCSV(strings.NewReader(buildCSV(item, "Shure", nil))) if err != nil { t.Fatalf("ParseCSV: %v", err) } @@ -94,20 +97,20 @@ func TestRoundTrip_serialized(t *testing.T) { item := equipment.Equipment{ Name: "Sony A7 IV", - TrackingType: equipment.Serialized, - UsageType: equipment.Rental, + TrackingType: tracking.Serialized, + UsageType: usage.Rental, CategoryName: "Camera", LocationName: "Main Warehouse", Notes: "Full-frame mirrorless camera", Pricing: equipment.Pricing{ - RentalPrice: equipment.ParseCents("80.00"), - PurchasePrice: equipment.ParseCents("2800.00"), + RentalPrice: units.ParseCents("80.00"), + PurchasePrice: units.ParseCents("2800.00"), }, Properties: equipment.Properties{ - Weight: equipment.ParseGrams("0.659"), - Width: equipment.ParseMillimeters("13.1"), - Height: equipment.ParseMillimeters("9.6"), - Depth: equipment.ParseMillimeters("8.0"), + Weight: units.ParseGrams("0.659"), + Width: units.ParseMillimeters("13.1"), + Height: units.ParseMillimeters("9.6"), + Depth: units.ParseMillimeters("8.0"), }, } units := []equipment.Unit{ @@ -115,7 +118,7 @@ func TestRoundTrip_serialized(t *testing.T) { StatusID: 1, SerialNumber: "SN-A7IV-001", ManufacturerSerialNumber: "7-000001", - PurchasePrice: equipment.ParseCents("2800.00"), + PurchasePrice: units.ParseCents("2800.00"), PurchasedAt: &purchasedAt, NextInspectionAt: &inspectAt, }, @@ -123,14 +126,14 @@ func TestRoundTrip_serialized(t *testing.T) { StatusID: 1, SerialNumber: "SN-A7IV-002", ManufacturerSerialNumber: "7-000002", - PurchasePrice: equipment.ParseCents("2800.00"), + PurchasePrice: units.ParseCents("2800.00"), PurchasedAt: &purchasedAt, NextInspectionAt: &inspectAt, Remark: "Minor scratch on top plate", }, } - rows, err := imports.ParseCSV(strings.NewReader(buildCSV(item, "Sony", units))) + rows, err := equipmentimports.ParseCSV(strings.NewReader(buildCSV(item, "Sony", units))) if err != nil { t.Fatalf("ParseCSV: %v", err) } @@ -163,22 +166,22 @@ func TestRoundTrip_serialized(t *testing.T) { func TestRoundTrip_kit(t *testing.T) { item := equipment.Equipment{ Name: "Pelican 1510 Case", - Type: equipment.Kit, - TrackingType: equipment.Serialized, - UsageType: equipment.Rental, + Type: equipment.KitType, + TrackingType: tracking.Serialized, + UsageType: usage.Rental, CategoryName: "Case", LocationName: "Main Warehouse", Pricing: equipment.Pricing{ - RentalPrice: equipment.ParseCents("10.00"), - PurchasePrice: equipment.ParseCents("120.00"), + RentalPrice: units.ParseCents("10.00"), + PurchasePrice: units.ParseCents("120.00"), }, } purchasedAt := int64(1704844800) // 2024-01-10 UTC units := []equipment.Unit{ - {StatusID: 1, SerialNumber: "PC-1510-001", PurchasePrice: equipment.ParseCents("120.00"), PurchasedAt: &purchasedAt}, + {StatusID: 1, SerialNumber: "PC-1510-001", PurchasePrice: units.ParseCents("120.00"), PurchasedAt: &purchasedAt}, } - rows, err := imports.ParseCSV(strings.NewReader(buildCSV(item, "Pelican", units))) + rows, err := equipmentimports.ParseCSV(strings.NewReader(buildCSV(item, "Pelican", units))) if err != nil { t.Fatalf("ParseCSV: %v", err) } diff --git a/internal/equipment/imports/rows.go b/internal/equipmentimports/rows.go similarity index 80% rename from internal/equipment/imports/rows.go rename to internal/equipmentimports/rows.go index ae86482..e575157 100644 --- a/internal/equipment/imports/rows.go +++ b/internal/equipmentimports/rows.go @@ -13,14 +13,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package imports provides imports functionality. -package imports +// Package equipmentimports provides imports functionality. +package equipmentimports import ( "strconv" "time" "github.com/bit8bytes/gearberg/internal/equipment" + "github.com/bit8bytes/gearberg/internal/equipment/tracking" ) // RowsForItem returns the CSV data rows for one equipment item using the column @@ -36,20 +37,20 @@ func RowsForItem(item equipment.Equipment, mfrName string, units []equipment.Uni item.CategoryName, mfrName, item.LocationName, - item.Pricing.RentalPrice.ToDecimal(), - item.Pricing.PurchasePrice.ToDecimal(), + item.Pricing.RentalPrice.String(), + item.Pricing.PurchasePrice.String(), item.Notes, - item.Properties.Weight.ToKG(), - item.Properties.Width.ToCM(), - item.Properties.Height.ToCM(), - item.Properties.Depth.ToCM(), - item.Properties.Voltage.ToV(), - item.Properties.Current.ToA(), - item.Properties.Power.ToW(), + item.Properties.Weight.String(), + item.Properties.Width.String(), + item.Properties.Height.String(), + item.Properties.Depth.String(), + item.Properties.Voltage.String(), + item.Properties.Current.String(), + item.Properties.Power.String(), item.Properties.WireGauge.String(), } - if item.TrackingType != equipment.Serialized { + if item.TrackingType != tracking.Serialized { row := make([]string, len(ExpectedHeaders)) copy(row, base) row[17] = strconv.FormatInt(item.TotalStock, 10) @@ -64,7 +65,7 @@ func RowsForItem(item equipment.Equipment, mfrName string, units []equipment.Uni row[18] = equipmentTypeLabel row[19] = u.SerialNumber row[20] = u.ManufacturerSerialNumber - row[21] = u.PurchasePrice.ToDecimal() + row[21] = u.PurchasePrice.String() row[22] = FormatExportDate(u.PurchasedAt) row[23] = FormatExportDate(u.NextInspectionAt) row[24] = FormatExportActive(u.IsActive()) diff --git a/internal/equipment/imports/service.go b/internal/equipmentimports/service.go similarity index 85% rename from internal/equipment/imports/service.go rename to internal/equipmentimports/service.go index 7761714..fb95314 100644 --- a/internal/equipment/imports/service.go +++ b/internal/equipmentimports/service.go @@ -13,27 +13,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package imports provides imports functionality. -package imports +// Package equipmentimports provides imports functionality. +package equipmentimports import ( "context" "database/sql" "fmt" + "math" "strings" "github.com/bit8bytes/gearberg/internal/equipment" + "github.com/bit8bytes/gearberg/internal/equipment/usage" + "github.com/bit8bytes/gearberg/internal/pagination" "github.com/bit8bytes/gearberg/internal/serial" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/uid" + "github.com/bit8bytes/gearberg/internal/units" ) -// EquipmentWriter is the subset of inventory.Service used by the import service. -type EquipmentWriter interface { - ListAll(ctx context.Context, orgID string) ([]equipment.Equipment, error) - CreateBulkTx(ctx context.Context, tx *sql.Tx, c equipment.CreateBulkEquipment) (*equipment.Equipment, error) - CreateSerialized(ctx context.Context, tx *sql.Tx, c equipment.CreateSerializedEquipment) (*equipment.Equipment, error) -} - // CategoryEnsurer resolves or creates categories by name. type CategoryEnsurer interface { Upsert(ctx context.Context, orgID, name string) (string, error) @@ -53,15 +50,15 @@ type LocationEnsurer interface { type Service struct { repo *Repository db *sql.DB - inventory EquipmentWriter + equipment *equipment.Repository categories CategoryEnsurer manufacturers ManufacturerEnsurer locations LocationEnsurer } // NewService returns a new Service. -func NewService(repo *Repository, db *sql.DB, inv EquipmentWriter, cats CategoryEnsurer, mfrs ManufacturerEnsurer, locs LocationEnsurer) *Service { - return &Service{repo: repo, db: db, inventory: inv, categories: cats, manufacturers: mfrs, locations: locs} +func NewService(repo *Repository, db *sql.DB, equip *equipment.Repository, cats CategoryEnsurer, mfrs ManufacturerEnsurer, locs LocationEnsurer) *Service { + return &Service{repo: repo, db: db, equipment: equip, categories: cats, manufacturers: mfrs, locations: locs} } // Stage deletes any existing staging rows for the org, then validates and stages @@ -71,7 +68,7 @@ func (s *Service) Stage(ctx context.Context, orgID string, rawRows []RawRow) (st return "", fmt.Errorf("Stage: %w", err) } - existing, err := s.inventory.ListAll(ctx, orgID) + existing, _, err := s.equipment.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32}) if err != nil { return "", fmt.Errorf("Stage: %w", err) } @@ -80,10 +77,10 @@ func (s *Service) Stage(ctx context.Context, orgID string, rawRows []RawRow) (st existingByName[strings.ToLower(item.Name)] = item.ID } - importID := ksuid.New().String() + importID := uid.New() for i, raw := range rawRows { row := Row{ - ID: ksuid.New().String(), + ID: uid.New(), ImportID: importID, OrgID: orgID, RowNumber: int64(i + 1), @@ -299,29 +296,28 @@ func (s *Service) resolveLookups(row Row, lk commitLookups) (catID, mfrID, locID } func buildBase(row Row, catID, mfrID, locID string) equipment.Base { - usageType := equipment.Rental return equipment.Base{ OrgID: row.OrgID, - UsageTypeID: usageType.ID(), + UsageTypeID: usage.Rental.ID(), Name: row.Name, CategoryID: catID, ManufacturerID: mfrID, LocationID: locID, Notes: row.Notes, - EquipmentType: equipment.TypeFromStringOrDefault(strings.ToLower(strings.TrimSpace(row.EquipmentTypeLabel))), + EquipmentType: equipment.ParseOrDefault(strings.ToLower(strings.TrimSpace(row.EquipmentTypeLabel))), Pricing: equipment.Pricing{ - PurchasePrice: equipment.ParseCents(row.ResalePrice), - RentalPrice: equipment.ParseCents(row.RentalPrice), + PurchasePrice: units.ParseCents(row.ResalePrice), + RentalPrice: units.ParseCents(row.RentalPrice), }, Properties: equipment.Properties{ - Weight: equipment.ParseGrams(row.WeightG), - Width: equipment.ParseMillimeters(row.WidthMm), - Height: equipment.ParseMillimeters(row.HeightMm), - Depth: equipment.ParseMillimeters(row.DepthMm), - Power: equipment.ParseMilliwatts(row.PowerMw), - Current: equipment.ParseMilliamps(row.CurrentMa), - Voltage: equipment.ParseVolts(row.VoltageMv), - WireGauge: equipment.ParseWireGauge(row.WireGaugeMM2X100), + Weight: units.ParseGrams(row.WeightG), + Width: units.ParseMillimeters(row.WidthMm), + Height: units.ParseMillimeters(row.HeightMm), + Depth: units.ParseMillimeters(row.DepthMm), + Power: units.ParseMilliwatts(row.PowerMw), + Current: units.ParseMilliamps(row.CurrentMa), + Voltage: units.ParseVolts(row.VoltageMv), + WireGauge: units.ParseWireGauge(row.WireGaugeMM2X100), }, } } @@ -329,8 +325,8 @@ func buildBase(row Row, catID, mfrID, locID string) equipment.Base { func (s *Service) commitBulkRow(ctx context.Context, tx *sql.Tx, row Row, lk commitLookups) error { catID, mfrID, locID := s.resolveLookups(row, lk) base := buildBase(row, catID, mfrID, locID) - if _, err := s.inventory.CreateBulkTx(ctx, tx, equipment.CreateBulkEquipment{ - ID: ksuid.New().String(), + if _, err := s.equipment.CreateBulk(ctx, tx, equipment.CreateBulkEquipment{ + ID: uid.New(), Base: base, TotalStock: equipment.ParseQuantity(row.Quantity), }); err != nil { @@ -346,12 +342,12 @@ func buildUnit(row Row, equipmentID string) equipment.CreateUnit { } isActive := !strings.EqualFold(row.UnitIsActive, "false") && row.UnitIsActive != "0" return equipment.CreateUnit{ - ID: ksuid.New().String(), + ID: uid.New(), EquipmentID: equipmentID, SerialNumber: sn, ManufacturerSerialNumber: row.UnitManufacturerSerial, Remark: row.UnitRemark, - PurchasePrice: equipment.ParseCents(row.UnitPurchasePrice), + PurchasePrice: units.ParseCents(row.UnitPurchasePrice), PurchasedAt: equipment.ParseDate(row.UnitPurchasedAt), NextInspectionAt: equipment.ParseDate(row.NextInspectionAt), IsActive: isActive, @@ -362,12 +358,12 @@ func buildUnit(row Row, equipmentID string) equipment.CreateUnit { func (s *Service) commitSerializedGroup(ctx context.Context, tx *sql.Tx, first Row, rows []Row, lk commitLookups) error { catID, mfrID, locID := s.resolveLookups(first, lk) base := buildBase(first, catID, mfrID, locID) - itemID := ksuid.New().String() + itemID := uid.New() units := make([]equipment.CreateUnit, 0, len(rows)) for _, row := range rows { units = append(units, buildUnit(row, itemID)) } - if _, err := s.inventory.CreateSerialized(ctx, tx, equipment.CreateSerializedEquipment{ + if _, err := s.equipment.CreateSerialized(ctx, tx, equipment.CreateSerializedEquipment{ ID: itemID, Base: base, Units: units, diff --git a/internal/equipment/imports/template.csv b/internal/equipmentimports/template.csv similarity index 100% rename from internal/equipment/imports/template.csv rename to internal/equipmentimports/template.csv diff --git a/internal/html/html.go b/internal/html/html.go index 682c0da..f48fe08 100644 --- a/internal/html/html.go +++ b/internal/html/html.go @@ -91,31 +91,19 @@ func (rnd *HTML) Handle(fn httperr.HandlerFunc) http.HandlerFunc { func (rnd *HTML) Render(w http.ResponseWriter, _ *http.Request, status int, page pages.Page, data any) *httperr.Error { tmpl, ok := rnd.cache[page.File] if !ok { - return &httperr.Error{ - Error: fmt.Errorf("template not found: %v", page.File), - Message: "Template not found.", - Code: http.StatusNotFound, - } + return httperr.InternalServerError(fmt.Errorf("template not found: %v", page.File)) } buffer := new(bytes.Buffer) if err := tmpl.ExecuteTemplate(buffer, "root", data); err != nil { - return &httperr.Error{ - Error: fmt.Errorf("render: %w", err), - Message: "Failed to render page.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("render: %w", err)) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if _, err := buffer.WriteTo(w); err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to write response.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("render write: %w", err)) } return nil } @@ -125,22 +113,14 @@ func (rnd *HTML) Render(w http.ResponseWriter, _ *http.Request, status int, page func (rnd *HTML) RenderFragment(w http.ResponseWriter, _ *http.Request, status int, name string, data any) *httperr.Error { buffer := new(bytes.Buffer) if err := rnd.base.ExecuteTemplate(buffer, name, data); err != nil { - return &httperr.Error{ - Error: fmt.Errorf("render fragment %q: %w", name, err), - Message: "Failed to render fragment.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("render fragment %q: %w", name, err)) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if _, err := buffer.WriteTo(w); err != nil { - return &httperr.Error{ - Error: err, - Message: "Failed to write response.", - Code: http.StatusInternalServerError, - } + return httperr.InternalServerError(fmt.Errorf("render fragment write %q: %w", name, err)) } return nil } diff --git a/internal/httperr/httperr.go b/internal/httperr/httperr.go index 8d5a0e8..5fc960c 100644 --- a/internal/httperr/httperr.go +++ b/internal/httperr/httperr.go @@ -31,3 +31,32 @@ type Error struct { // HandlerFunc is an http.HandlerFunc variant that returns an *Error instead of writing error // responses directly. Handlers return nil on success; a non-nil value is handled by the renderer. type HandlerFunc func(http.ResponseWriter, *http.Request) *Error + +// BadRequest returns a 400 error with a generic "Bad request." message. +func BadRequest(err error) *Error { + return &Error{ + Error: err, + Message: "Bad request.", + Code: http.StatusBadRequest, + } +} + +// InternalServerError returns a 500 error with a generic "Something went wrong." message. +// Do not expose detail in the message. Log it via the Error field instead. +func InternalServerError(err error) *Error { + return &Error{ + Error: err, + Message: "Something went wrong.", + Code: http.StatusInternalServerError, + } +} + +// NotFound returns a 404 error with a generic "Not found." message. +// Do not expose deail in message. Log it via the Error field instead. +func NotFound(err error) *Error { + return &Error{ + Error: err, + Message: "Not found.", + Code: http.StatusNotFound, + } +} diff --git a/internal/equipment/locations/form.go b/internal/locations/form.go similarity index 100% rename from internal/equipment/locations/form.go rename to internal/locations/form.go diff --git a/internal/equipment/locations/repository.go b/internal/locations/repository.go similarity index 88% rename from internal/equipment/locations/repository.go rename to internal/locations/repository.go index 9ebd6f0..79b06b3 100644 --- a/internal/equipment/locations/repository.go +++ b/internal/locations/repository.go @@ -49,8 +49,8 @@ func (r *Repository) Count(ctx context.Context, orgID string) (int64, error) { return n, nil } -// GetByOrgID returns all locations belonging to orgID, ordered by name. -func (r *Repository) GetByOrgID(ctx context.Context, orgID string) ([]Location, error) { +// List returns all locations belonging to orgID, ordered by name. +func (r *Repository) List(ctx context.Context, orgID string) ([]Location, error) { rows, err := r.locations.GetByOrgID(ctx, orgID) if err != nil { return nil, fmt.Errorf("GetByOrgID: %w", err) @@ -62,8 +62,8 @@ func (r *Repository) GetByOrgID(ctx context.Context, orgID string) ([]Location, return result, nil } -// GetByID returns the location with id, or ErrNotFound when it does not exist. -func (r *Repository) GetByID(ctx context.Context, id string) (*Location, error) { +// Get returns the location with id, or ErrNotFound when it does not exist. +func (r *Repository) Get(ctx context.Context, id string) (*Location, error) { row, err := r.locations.GetByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -88,8 +88,15 @@ func (r *Repository) GetByName(ctx context.Context, orgID, name string) (*Locati return &m, nil } +type createParams struct { + ID string + ParentWarehouseLocationID *string + OrgID string + Name string +} + // Create inserts a new location. -func (r *Repository) Create(ctx context.Context, c CreateLocation) (*Location, error) { +func (r *Repository) Create(ctx context.Context, c createParams) (*Location, error) { var parentID sql.NullString if c.ParentWarehouseLocationID != nil { parentID = sql.NullString{String: *c.ParentWarehouseLocationID, Valid: true} diff --git a/internal/equipment/locations/service.go b/internal/locations/service.go similarity index 68% rename from internal/equipment/locations/service.go rename to internal/locations/service.go index 4d83b9b..2259752 100644 --- a/internal/equipment/locations/service.go +++ b/internal/locations/service.go @@ -22,7 +22,16 @@ import ( "fmt" "strings" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/uid" +) + +var ( + // ErrNotFound is returned when a location does not exist. + ErrNotFound = errors.New("location not found") + // ErrConflict is returned when a unique constraint is violated. + ErrConflict = errors.New("location already exists") + // ErrLimitExceeded is returned when an org's location limit is reached. + ErrLimitExceeded = errors.New("location limit exceeded") ) // Options holds configuration for the location service. @@ -41,14 +50,17 @@ func NewService(repo *Repository, opts Options) *Service { return &Service{repo: repo, opts: opts} } -// MaxLocations returns the configured maximum number of locations per org. -func (s *Service) MaxLocations() int { - return s.opts.MaxLocations +// Location represents a storage location belonging to an org. +type Location struct { + ID string + ParentWarehouseLocationID *string + OrgID string + Name string } -// GetByOrgID returns all locations belonging to orgID. -func (s *Service) GetByOrgID(ctx context.Context, orgID string) ([]Location, error) { - locs, err := s.repo.GetByOrgID(ctx, orgID) +// List returns all locations belonging to orgID. +func (s *Service) List(ctx context.Context, orgID string) ([]Location, error) { + locs, err := s.repo.List(ctx, orgID) if err != nil { return nil, fmt.Errorf("GetByOrgID: %w", err) } @@ -57,13 +69,20 @@ func (s *Service) GetByOrgID(ctx context.Context, orgID string) ([]Location, err // GetByID returns the location with id. func (s *Service) GetByID(ctx context.Context, id string) (*Location, error) { - loc, err := s.repo.GetByID(ctx, id) + loc, err := s.repo.Get(ctx, id) if err != nil { return nil, fmt.Errorf("GetByID: %w", err) } return loc, nil } +// CreateLocation holds the data required to create a new location. +type CreateLocation struct { + ParentWarehouseLocationID *string + OrgID string + Name string +} + // Create creates a new location, enforcing the configured MaxLocations limit per org. func (s *Service) Create(ctx context.Context, c CreateLocation) (*Location, error) { count, err := s.repo.Count(ctx, c.OrgID) @@ -74,17 +93,24 @@ func (s *Service) Create(ctx context.Context, c CreateLocation) (*Location, erro return nil, ErrLimitExceeded } - if c.ID == "" { - c.ID = ksuid.New().String() - } - - loc, err := s.repo.Create(ctx, c) + loc, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), + ParentWarehouseLocationID: c.ParentWarehouseLocationID, + OrgID: c.OrgID, + Name: c.Name, + }) if err != nil { return nil, fmt.Errorf("Create: %w", err) } return loc, nil } +// UpdateLocation holds the data required to update a location. +type UpdateLocation struct { + ID string + Name string +} + // Update updates the name of a location. func (s *Service) Update(ctx context.Context, u UpdateLocation) (*Location, error) { loc, err := s.repo.Update(ctx, u) @@ -110,8 +136,8 @@ func (s *Service) Upsert(ctx context.Context, orgID, name string) (string, error if name == "" { return "", fmt.Errorf("Upsert: name is blank") } - _, err := s.repo.Create(ctx, CreateLocation{ - ID: ksuid.New().String(), + _, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), OrgID: orgID, Name: name, }) diff --git a/internal/equipment/manufacturers/form.go b/internal/manufacturers/form.go similarity index 100% rename from internal/equipment/manufacturers/form.go rename to internal/manufacturers/form.go diff --git a/internal/equipment/manufacturers/repository.go b/internal/manufacturers/repository.go similarity index 90% rename from internal/equipment/manufacturers/repository.go rename to internal/manufacturers/repository.go index fdecd62..cd7551f 100644 --- a/internal/equipment/manufacturers/repository.go +++ b/internal/manufacturers/repository.go @@ -49,8 +49,8 @@ func (r *Repository) Count(ctx context.Context, orgID string) (int64, error) { return n, nil } -// GetByOrgID returns all manufacturers belonging to orgID. -func (r *Repository) GetByOrgID(ctx context.Context, orgID string) ([]Manufacturer, error) { +// List returns all manufacturers belonging to orgID. +func (r *Repository) List(ctx context.Context, orgID string) ([]Manufacturer, error) { rows, err := r.manufacturers.GetByOrgID(ctx, orgID) if err != nil { return nil, fmt.Errorf("GetByOrgID: %w", err) @@ -62,8 +62,8 @@ func (r *Repository) GetByOrgID(ctx context.Context, orgID string) ([]Manufactur return result, nil } -// GetByID returns the manufacturer with id, or ErrNotFound when it does not exist. -func (r *Repository) GetByID(ctx context.Context, id string) (*Manufacturer, error) { +// Get returns the manufacturer with id, or ErrNotFound when it does not exist. +func (r *Repository) Get(ctx context.Context, id string) (*Manufacturer, error) { row, err := r.manufacturers.GetByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -75,8 +75,14 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*Manufacturer, err return &m, nil } +type createParams struct { + ID string + OrgID string + Name string +} + // Create inserts a new manufacturer. -func (r *Repository) Create(ctx context.Context, c CreateManufacturer) (*Manufacturer, error) { +func (r *Repository) Create(ctx context.Context, c createParams) (*Manufacturer, error) { row, err := r.manufacturers.Create(ctx, genmfr.CreateParams{ ID: c.ID, OrgID: c.OrgID, diff --git a/internal/equipment/manufacturers/service.go b/internal/manufacturers/service.go similarity index 70% rename from internal/equipment/manufacturers/service.go rename to internal/manufacturers/service.go index c119407..6f77140 100644 --- a/internal/equipment/manufacturers/service.go +++ b/internal/manufacturers/service.go @@ -22,7 +22,18 @@ import ( "fmt" "strings" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/uid" +) + +var ( + // ErrNotFound is returned when a manufacturer does not exist. + ErrNotFound = errors.New("manufacturer not found") + // ErrConflict is returned when a unique constraint is violated. + ErrConflict = errors.New("manufacturer already exists") + // ErrLimitExceeded is returned when an org's manufacturer limit is reached. + ErrLimitExceeded = errors.New("manufacturer limit exceeded") + // ErrInUse is returned when a manufacturer cannot be deleted due to assigned inventory items. + ErrInUse = errors.New("manufacturer is in use and cannot be deleted") ) // Options holds configuration for the manufacturer service. @@ -41,29 +52,39 @@ func NewService(repo *Repository, opts Options) *Service { return &Service{repo: repo, opts: opts} } -// MaxManufacturers returns the configured maximum number of manufacturers per org. -func (s *Service) MaxManufacturers() int { - return s.opts.MaxManufacturers +// Manufacturer represents a manufacturer belonging to an org. +type Manufacturer struct { + ID string + OrgID string + Name string + UpdatedAt int64 + CreatedAt int64 } // List returns all manufacturers belonging to orgID. func (s *Service) List(ctx context.Context, orgID string) ([]Manufacturer, error) { - manufacturers, err := s.repo.GetByOrgID(ctx, orgID) + manufacturers, err := s.repo.List(ctx, orgID) if err != nil { return nil, fmt.Errorf("failed to get manufacturers: %w", err) } return manufacturers, nil } -// GetByID returns the manufacturer with id. -func (s *Service) GetByID(ctx context.Context, id string) (*Manufacturer, error) { - manufacturer, err := s.repo.GetByID(ctx, id) +// Get returns the manufacturer with id. +func (s *Service) Get(ctx context.Context, id string) (*Manufacturer, error) { + manufacturer, err := s.repo.Get(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get manufacturer: %w", err) } return manufacturer, nil } +// CreateManufacturer holds the data required to create a new manufacturer. +type CreateManufacturer struct { + OrgID string + Name string +} + // Create creates a new manufacturer, enforcing the configured MaxManufacturers limit per org. func (s *Service) Create(ctx context.Context, c CreateManufacturer) (*Manufacturer, error) { count, err := s.repo.Count(ctx, c.OrgID) @@ -74,13 +95,23 @@ func (s *Service) Create(ctx context.Context, c CreateManufacturer) (*Manufactur return nil, ErrLimitExceeded } - manufacturer, err := s.repo.Create(ctx, c) + manufacturer, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), + OrgID: c.OrgID, + Name: c.Name, + }) if err != nil { return nil, fmt.Errorf("failed to create manufacturer: %w", err) } return manufacturer, nil } +// UpdateManufacturer holds the data required to update a manufacturer. +type UpdateManufacturer struct { + ID string + Name string +} + // Update updates the name of a manufacturer. func (s *Service) Update(ctx context.Context, u UpdateManufacturer) (*Manufacturer, error) { manufacturer, err := s.repo.Update(ctx, u) @@ -98,8 +129,8 @@ func (s *Service) Upsert(ctx context.Context, orgID, name string) (string, error if name == "" { return "", fmt.Errorf("Upsert: name is blank") } - _, err := s.repo.Create(ctx, CreateManufacturer{ - ID: ksuid.New().String(), + _, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), OrgID: orgID, Name: name, }) diff --git a/internal/money/money.go b/internal/money/money.go index e241bf1..629357f 100644 --- a/internal/money/money.go +++ b/internal/money/money.go @@ -19,6 +19,7 @@ package money import ( "math" "strconv" + "strings" ) // ISO4217 lists the ISO-4217 codes exposed in the UI. @@ -37,9 +38,9 @@ func (c Currency) String() string { return string(c) } // VatRate is a VAT rate stored as basis points (e.g. 1900 = 19%). type VatRate int64 -// Percent converts the stored basis-point value back to a human-readable +// String converts the stored basis-point value back to a human-readable // percentage string suitable for pre-filling the settings form (e.g. 1900 → "19"). -func (v VatRate) Percent() string { +func (v VatRate) String() string { f := float64(v) / 100 if f == math.Trunc(f) { return strconv.FormatFloat(f, 'f', 0, 64) @@ -47,6 +48,18 @@ func (v VatRate) Percent() string { return strconv.FormatFloat(f, 'f', 2, 64) } +// Percent is an alias for String, kept for backwards compatibility. +func (v VatRate) Percent() string { return v.String() } + +// ParseVatRate converts a percentage string (e.g. "19" or "19,5") to a VatRate in basis points. +func ParseVatRate(s string) VatRate { + v, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", "."), 64) + if err != nil { + return 0 + } + return Round(v) +} + // Display formats the VAT rate as a percentage string for display (e.g. 1900 → "19%"). // Returns "" when zero. func (v VatRate) Display() string { diff --git a/internal/orgs/settings/form.go b/internal/orgs/settings/form.go index 8444711..2ecfcb4 100644 --- a/internal/orgs/settings/form.go +++ b/internal/orgs/settings/form.go @@ -19,7 +19,6 @@ package settings import ( "fmt" "net/http" - "strconv" "strings" "github.com/bit8bytes/gearberg/internal/money" @@ -29,9 +28,9 @@ import ( // Form holds the parsed form input and validation state for org settings requests. type Form struct { - Currency string - VatRate string // entered as a percentage e.g. "19" or "19,5" - Timezone string + Currency money.Currency + VatRate money.VatRate + Timezone timezone.Timezone validator.Validator } @@ -49,42 +48,29 @@ func Parse(r *http.Request) (Form, error) { if err := r.ParseForm(); err != nil { return f, fmt.Errorf("parse form: %w", err) } - f.Currency = strings.TrimSpace(r.PostForm.Get("currency")) - f.VatRate = strings.TrimSpace(r.PostForm.Get("vat_rate")) - f.Timezone = strings.TrimSpace(r.PostForm.Get("timezone")) + f.Currency = money.Currency(strings.TrimSpace(r.PostForm.Get("currency"))) + f.VatRate = money.ParseVatRate(strings.TrimSpace(r.PostForm.Get("vat_rate"))) + f.Timezone = timezone.Timezone(strings.TrimSpace(r.PostForm.Get("timezone"))) return f, nil } // Validate checks form fields and returns true when all checks pass. func (f *Form) Validate() bool { - f.Check(validator.PermittedValue(f.Currency, money.ISO4217...), "currency", "Must be a valid ISO-4217 currency code") - if validator.NotBlank(f.VatRate) { - v, err := strconv.ParseFloat(strings.ReplaceAll(f.VatRate, ",", "."), 64) - f.Check(err == nil, "vat_rate", "Must be a valid number") - f.Check(err != nil || (v >= 0 && v <= 100), "vat_rate", "Must be between 0 and 100") - } else { - f.AddError("vat_rate", "This field cannot be blank") - } - f.Check(validator.PermittedValue(f.Timezone, timezone.IANA...), "timezone", "Must be a valid IANA timezone") + f.Check(validator.PermittedValue(string(f.Currency), money.ISO4217...), "currency", "Must be a valid ISO-4217 currency code") + f.Check(f.VatRate >= 0 && f.VatRate <= 10000, "vat_rate", "Must be between 0 and 100") + f.Check(validator.PermittedValue(string(f.Timezone), timezone.IANA...), "timezone", "Must be a valid IANA timezone") return f.Valid() } -// FormFromOrgSettings pre-populates a Form from stored OrgSettings. -func FormFromOrgSettings(s *OrgSettings) Form { +// Form pre-populates a Form from the stored OrgSettings. Safe to call on a nil receiver. +func (s *OrgSettings) Form() Form { f := Form{} f.Errors = make(map[string]string) if s == nil { return f } - f.Currency = s.Currency.String() - f.VatRate = s.VatRate.Percent() - f.Timezone = s.Timezone.String() + f.Currency = s.Currency + f.VatRate = s.VatRate + f.Timezone = s.Timezone return f } - -// ParsedVatRate converts the entered percentage string to a VatRate (basis points). -// Call only after Validate() returns true. -func (f *Form) ParsedVatRate() money.VatRate { - v, _ := strconv.ParseFloat(strings.ReplaceAll(f.VatRate, ",", "."), 64) - return money.Round(v) -} diff --git a/internal/orgs/settings/form_test.go b/internal/orgs/settings/form_test.go index 902e453..5c4277a 100644 --- a/internal/orgs/settings/form_test.go +++ b/internal/orgs/settings/form_test.go @@ -16,25 +16,28 @@ package settings import ( "testing" + + "github.com/bit8bytes/gearberg/internal/money" + "github.com/bit8bytes/gearberg/internal/timezone" ) func TestValidate_PermittedCurrencyAndTimezone(t *testing.T) { cases := []struct { name string - currency string - vatRate string - timezone string + currency money.Currency + vatRate money.VatRate + timezone timezone.Timezone valid bool }{ - {"valid", "EUR", "19", "Europe/Berlin", true}, - {"vat rate zero", "EUR", "0", "Europe/Berlin", true}, - {"vat rate one", "EUR", "100", "Europe/Berlin", true}, - {"vat rate below range", "EUR", "-1", "Europe/Berlin", false}, - {"vat rate above range", "EUR", "101", "Europe/Berlin", false}, - {"invalid currency", "XYZ", "19", "Europe/Berlin", false}, - {"blank currency", "", "19", "Europe/Berlin", false}, - {"invalid timezone", "EUR", "19", "Mars/Olympus", false}, - {"blank timezone", "EUR", "19", "", false}, + {"valid", "EUR", 1900, "Europe/Berlin", true}, + {"vat rate zero", "EUR", 0, "Europe/Berlin", true}, + {"vat rate one", "EUR", 10000, "Europe/Berlin", true}, + {"vat rate below range", "EUR", -100, "Europe/Berlin", false}, + {"vat rate above range", "EUR", 10100, "Europe/Berlin", false}, + {"invalid currency", "XYZ", 1900, "Europe/Berlin", false}, + {"blank currency", "", 1900, "Europe/Berlin", false}, + {"invalid timezone", "EUR", 1900, "Mars/Olympus", false}, + {"blank timezone", "EUR", 1900, "", false}, } for _, tc := range cases { diff --git a/internal/orgs/settings/repository.go b/internal/orgs/settings/repository.go index 359c920..04e78c3 100644 --- a/internal/orgs/settings/repository.go +++ b/internal/orgs/settings/repository.go @@ -53,11 +53,16 @@ func (r *Repository) GetByOrgID(ctx context.Context, orgID string) (*OrgSettings return toModel(row), nil } +type createParams struct { + ID string + OrgID string +} + // Create inserts a new settings row with migration defaults for currency, vat_rate, and timezone. -func (r *Repository) Create(ctx context.Context, id, orgID string) (*OrgSettings, error) { +func (r *Repository) Create(ctx context.Context, params createParams) (*OrgSettings, error) { row, err := r.settings.Create(ctx, gencs.CreateParams{ - ID: id, - OrgID: orgID, + ID: params.ID, + OrgID: params.OrgID, }) if err != nil { return nil, fmt.Errorf("Create: %w", err) diff --git a/internal/orgs/settings/service.go b/internal/orgs/settings/service.go index 91d55ec..4cf330a 100644 --- a/internal/orgs/settings/service.go +++ b/internal/orgs/settings/service.go @@ -19,6 +19,8 @@ package settings import ( "context" "fmt" + + "github.com/bit8bytes/gearberg/internal/uid" ) // Service implements business logic for org settings. @@ -41,8 +43,11 @@ func (s *Service) Get(ctx context.Context, orgID string) (*OrgSettings, error) { } // Create inserts new settings for orgID using the migration defaults. -func (s *Service) Create(ctx context.Context, id, orgID string) (*OrgSettings, error) { - settings, err := s.repo.Create(ctx, id, orgID) +func (s *Service) Create(ctx context.Context, orgID string) (*OrgSettings, error) { + settings, err := s.repo.Create(ctx, createParams{ + ID: uid.New(), + OrgID: orgID, + }) if err != nil { return nil, fmt.Errorf("failed to create org settings: %w", err) } diff --git a/internal/storage/manager.go b/internal/storage/manager.go index 92364dd..cde697f 100644 --- a/internal/storage/manager.go +++ b/internal/storage/manager.go @@ -27,7 +27,7 @@ import ( "io" genstorage "github.com/bit8bytes/gearberg/internal/database/queries/gen/storage" - "github.com/segmentio/ksuid" + "github.com/bit8bytes/gearberg/internal/uid" ) // ErrStorageQuotaExceeded is returned by CheckQuota when an upload would push @@ -128,7 +128,7 @@ func (m *Manager) Put(ctx context.Context, orgID, key, filename string, r io.Rea return nil, err } row, err := m.queries.Create(ctx, genstorage.CreateParams{ - ID: ksuid.New().String(), + ID: uid.New(), OrgID: orgID, Key: key, Backend: s.Backend, diff --git a/internal/templates/pages/equipment/detail.tmpl b/internal/templates/pages/equipment/detail.tmpl index ac23dd5..2fd68eb 100644 --- a/internal/templates/pages/equipment/detail.tmpl +++ b/internal/templates/pages/equipment/detail.tmpl @@ -53,7 +53,7 @@
- +
@@ -89,7 +89,7 @@
- {{ if eq .Form.TypeID "bulk" }} + {{ if eq .Form.Type.String "bulk" }}
+