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" }}
+