From a006792c08abc3f370933f4f16f8e2e6580b1c8f Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 22 Aug 2026 12:58:06 -0400 Subject: [PATCH] refactor: this is a big refactor to unify and simplify the codebase. This was necessary because of the use of AI without always proper oversight and unnecessary nesting. The following has changed. Moved warehouse locations, manufacturers, and categories into the internal folder. Made use of the sqlc overrides to override domain types such as Milliwatts to avoid custom mappers which means less handwritten code. Moved ksuid into uid package and let the domain layer create the uid. The handler must not know about this. Renamed reRender() to fail(). Simplify GetFiltered, GetAllFiltered, and List into one equipment.List func. Added constuctor methods BadRequest, InternalServerError, and NotFound for the httperr pkg to handle generic errors and reduce handler boilerplate. Moved VAT into its own type. Unify form.go to use Parse() and Validate() funcs. Learnings are: Refactoring takes more time than writing less code and think longer about the acutal implementation. Dont rush for features, make sure the code is reliable and as small as possible. Dont take the linter to serious for cyclomatic complexity, sometimes its okay and go on. Ai is great and combined with small changes even better. Large changes, such as these should be very rare in future. --- cmd/web/handlers.go | 3 +- ...t:categories.go => handlers:categories.go} | 116 +++-- cmd/web/handlers:equipment.go | 407 ++++++++++++------ cmd/web/handlers:equipment:export.go | 21 +- cmd/web/handlers:equipment:import.go | 36 +- cmd/web/handlers:equipment:import_test.go | 14 +- ...ent:locations.go => handlers:locations.go} | 89 ++-- cmd/web/handlers:login.go | 33 +- ...facturers.go => handlers:manufacturers.go} | 103 ++--- cmd/web/handlers:media.go | 18 +- cmd/web/handlers:orgs.go | 63 +-- cmd/web/handlers:orgs:settings.go | 36 +- cmd/web/handlers:settings:account.go | 6 +- cmd/web/seed.go | 16 +- cmd/web/setup.go | 12 +- internal/accounts/service.go | 10 +- internal/{equipment => }/categories/form.go | 0 .../{equipment => }/categories/repository.go | 28 +- .../{equipment => }/categories/service.go | 59 ++- .../queries/gen/equipment/equipment.sql.go | 106 ++--- internal/equipment/categories/model.go | 52 --- internal/equipment/form.go | 236 ++++------ internal/equipment/helpers.go | 107 ----- internal/equipment/locations/model.go | 50 --- internal/equipment/manufacturers/model.go | 52 --- internal/equipment/mapping.go | 82 ---- internal/equipment/model.go | 181 ++------ internal/equipment/repository.go | 206 +++++---- internal/equipment/service.go | 199 +++------ .../tracking.go} | 22 +- internal/equipment/type.go | 28 +- .../{usagetype.go => usage/usage.go} | 20 +- .../imports => equipmentimports}/csv.go | 4 +- .../imports => equipmentimports}/csv_test.go | 36 +- .../imports => equipmentimports}/model.go | 4 +- .../repository.go | 4 +- .../roundtrip_test.go | 65 +-- .../imports => equipmentimports}/rows.go | 27 +- .../imports => equipmentimports}/service.go | 66 ++- .../imports => equipmentimports}/template.csv | 0 internal/html/html.go | 30 +- internal/httperr/httperr.go | 29 ++ internal/{equipment => }/locations/form.go | 0 .../{equipment => }/locations/repository.go | 17 +- internal/{equipment => }/locations/service.go | 56 ++- .../{equipment => }/manufacturers/form.go | 0 .../manufacturers/repository.go | 16 +- .../{equipment => }/manufacturers/service.go | 53 ++- internal/money/money.go | 17 +- internal/orgs/settings/form.go | 42 +- internal/orgs/settings/form_test.go | 27 +- internal/orgs/settings/repository.go | 11 +- internal/orgs/settings/service.go | 9 +- internal/storage/manager.go | 4 +- .../templates/pages/equipment/detail.tmpl | 5 +- internal/templates/pages/equipment/new.tmpl | 12 +- internal/templates/pages/equipment/print.tmpl | 2 +- internal/templates/pages/equipment/units.tmpl | 4 +- internal/units/units.go | 200 +++++++++ sqlc.sqlite.yml | 74 ++++ 60 files changed, 1568 insertions(+), 1657 deletions(-) rename cmd/web/{handlers:equipment:categories.go => handlers:categories.go} (64%) rename cmd/web/{handlers:equipment:locations.go => handlers:locations.go} (71%) rename cmd/web/{handlers:equipment:manufacturers.go => handlers:manufacturers.go} (65%) rename internal/{equipment => }/categories/form.go (100%) rename internal/{equipment => }/categories/repository.go (86%) rename internal/{equipment => }/categories/service.go (67%) delete mode 100644 internal/equipment/categories/model.go delete mode 100644 internal/equipment/locations/model.go delete mode 100644 internal/equipment/manufacturers/model.go delete mode 100644 internal/equipment/mapping.go rename internal/equipment/{trackingtypes.go => tracking/tracking.go} (73%) rename internal/equipment/{usagetype.go => usage/usage.go} (75%) rename internal/{equipment/imports => equipmentimports}/csv.go (98%) rename internal/{equipment/imports => equipmentimports}/csv_test.go (86%) rename internal/{equipment/imports => equipmentimports}/model.go (97%) rename internal/{equipment/imports => equipmentimports}/repository.go (98%) rename internal/{equipment/imports => equipmentimports}/roundtrip_test.go (74%) rename internal/{equipment/imports => equipmentimports}/rows.go (80%) rename internal/{equipment/imports => equipmentimports}/service.go (85%) rename internal/{equipment/imports => equipmentimports}/template.csv (100%) rename internal/{equipment => }/locations/form.go (100%) rename internal/{equipment => }/locations/repository.go (88%) rename internal/{equipment => }/locations/service.go (68%) rename internal/{equipment => }/manufacturers/form.go (100%) rename internal/{equipment => }/manufacturers/repository.go (90%) rename internal/{equipment => }/manufacturers/service.go (70%) create mode 100644 internal/units/units.go 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" }}
+