From d720c042aa2e4290625f3e691dd93626f8350aa4 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:12:07 -0400 Subject: [PATCH 01/16] docs: documented new import process which makes imports format agnostic and extensible. This includes data mapping from user provided data to Gearberg internal data. --- wiki/IMPORTS_ERD.md | 60 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 wiki/IMPORTS_ERD.md diff --git a/wiki/IMPORTS_ERD.md b/wiki/IMPORTS_ERD.md new file mode 100644 index 0000000..bfdb66e --- /dev/null +++ b/wiki/IMPORTS_ERD.md @@ -0,0 +1,60 @@ +# Database: Format agnostic import process + +The goal is to let the user bring any format (start with csv), upload it, map it to Gearberg internal, and import it into the system. + +This requires a bigger refactor, but we are in alpha, so we can completly delete the current design and make this possible. + +We will gain easy of use and better adoption by designing a better import process. + +```mermaid +erDiagram + import_sessions { + text id PK + text org_id FK + text format "csv | json | excel" + text status "uploading | mapping | staged | committed" + text target_entity "equipment" + integer created_at "NOT NULL DEFAULT unixepoch()" + } + + import_data { + text id PK + text session_id "REFRENCES import_sessions(id) ON DELETE CASCADE" + integer row_number + blob data "JSON blob of raw source columns" + text status "new | error | skipped" + text error_message + text action "create | skip" + } + + import_mappings { + text id PK + text session_id "REFRENCES import_sessions(id) ON DELETE CASCADE" + text source_col "user column name e.g. Bezeichnung" + text target_field "internal field name e.g. name" + } + + import_sessions ||--o{ import_data : "has rows" + import_sessions ||--o{ import_mappings : "has mappings" +``` + +The flow consists of following steps: + +1. Upload CSV +2. Create import session +3. Populate `import_data` with raw rows +4. User maps source columns to Gearberg fields (`import_mappings`) +5. System validates and applies mappings, runs validation steps, marks rows +6. User reviews and resolves decision +7. User confirms and commit to inventory/equipment + +We don't support editing in the UI yet, but we can do this easily by adding an override field to the import data table. + +This flow can be put simply into: + +1. Stage (NewSession) +2. Review +3. Commit + +to abstract the complex logic behind a simple interface. + From 7c1262455ecc1deb32aa1cf971af466eed8f611f Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:20:00 -0400 Subject: [PATCH 02/16] chore: added migrations for import session, data, and mappings --- .../sqlite/20260823121431_import_sessions.sql | 16 ++++++++++++++ .../sqlite/20260823121436_import_data.sql | 21 +++++++++++++++++++ .../sqlite/20260823121445_import_mappings.sql | 17 +++++++++++++++ wiki/IMPORTS_ERD.md | 18 ++++++++-------- 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 internal/database/migrations/sqlite/20260823121431_import_sessions.sql create mode 100644 internal/database/migrations/sqlite/20260823121436_import_data.sql create mode 100644 internal/database/migrations/sqlite/20260823121445_import_mappings.sql diff --git a/internal/database/migrations/sqlite/20260823121431_import_sessions.sql b/internal/database/migrations/sqlite/20260823121431_import_sessions.sql new file mode 100644 index 0000000..3b86a3d --- /dev/null +++ b/internal/database/migrations/sqlite/20260823121431_import_sessions.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE import_sessions ( + id TEXT NOT NULL PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + format TEXT NOT NULL, -- csv | json | excel + status TEXT NOT NULL, -- uploading | mapping | staged | committed + target_entity TEXT NOT NULL, -- equipment | ... + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS import_sessions; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/20260823121436_import_data.sql b/internal/database/migrations/sqlite/20260823121436_import_data.sql new file mode 100644 index 0000000..47c508e --- /dev/null +++ b/internal/database/migrations/sqlite/20260823121436_import_data.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE import_data ( + id TEXT NOT NULL PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES import_sessions(id) ON DELETE CASCADE, + row_number INTEGER NOT NULL CHECK (row_number > 0), + data TEXT NOT NULL DEFAULT '{}', -- JSON blob of raw source columns + status TEXT NOT NULL DEFAULT 'new', -- new | error | needs_review + error_message TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL DEFAULT 'pending', -- create | skip | pending + UNIQUE (session_id, row_number) +) STRICT; + +CREATE INDEX import_data_session_id_idx ON import_data (session_id); +CREATE INDEX import_data_action_idx ON import_data (session_id, action); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS import_data; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/20260823121445_import_mappings.sql b/internal/database/migrations/sqlite/20260823121445_import_mappings.sql new file mode 100644 index 0000000..41a798a --- /dev/null +++ b/internal/database/migrations/sqlite/20260823121445_import_mappings.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE import_mappings ( + id TEXT NOT NULL PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES import_sessions(id) ON DELETE CASCADE, + source_col TEXT NOT NULL, -- user column name e.g. "Bezeichnung" + target_field TEXT NOT NULL, -- internal field name e.g. "equipment.name" + UNIQUE (session_id, source_col) +) STRICT; + +CREATE INDEX import_mappings_session_id_idx ON import_mappings (session_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS import_mappings; +-- +goose StatementEnd diff --git a/wiki/IMPORTS_ERD.md b/wiki/IMPORTS_ERD.md index bfdb66e..acf6f4e 100644 --- a/wiki/IMPORTS_ERD.md +++ b/wiki/IMPORTS_ERD.md @@ -19,19 +19,19 @@ erDiagram import_data { text id PK - text session_id "REFRENCES import_sessions(id) ON DELETE CASCADE" - integer row_number - blob data "JSON blob of raw source columns" - text status "new | error | skipped" - text error_message - text action "create | skip" + text session_id "REFERENCES import_sessions(id) ON DELETE CASCADE" + integer row_number "NOT NULL CHECK row_number > 0 UNIQUE session_id+row_number" + text data "NOT NULL DEFAULT {} JSON blob of raw source columns" + text status "NOT NULL DEFAULT new | error | needs_review" + text error_message "NOT NULL DEFAULT empty" + text action "NOT NULL DEFAULT pending | create | skip" } import_mappings { text id PK - text session_id "REFRENCES import_sessions(id) ON DELETE CASCADE" - text source_col "user column name e.g. Bezeichnung" - text target_field "internal field name e.g. name" + text session_id "REFERENCES import_sessions(id) ON DELETE CASCADE" + text source_col "NOT NULL UNIQUE per session e.g. Bezeichnung" + text target_field "NOT NULL e.g. name" } import_sessions ||--o{ import_data : "has rows" From 1af6ee25db2d89efc18633551a299bca9b523767 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:24:07 -0400 Subject: [PATCH 03/16] refactor: stripped equipmentimports out, removed import migration, and deleted obsolote code --- cmd/web/handlers:equipment:export.go | 81 ---- cmd/web/handlers:equipment:import.go | 131 ------ cmd/web/handlers:equipment:import_test.go | 134 ------ cmd/web/routes.go | 5 - cmd/web/setup.go | 6 - .../sqlite/00020_equipment_imports.sql | 52 --- ...sessions.sql => 00025_import_sessions.sql} | 0 ..._import_data.sql => 00026_import_data.sql} | 0 ...mappings.sql => 00027_import_mappings.sql} | 0 .../queries/gen/equipmentimports/db.go | 31 -- .../equipmentimports/equipment_imports.sql.go | 344 -------------- .../queries/gen/equipmentimports/models.go | 50 -- .../queries/gen/equipmentimports/querier.go | 21 - .../queries/sqlite/equipment_imports.sql | 63 --- internal/equipmentimports/csv.go | 65 --- internal/equipmentimports/importer.go | 119 ----- internal/equipmentimports/model.go | 201 -------- internal/equipmentimports/repository.go | 177 ------- internal/equipmentimports/rows.go | 103 ----- internal/equipmentimports/service.go | 436 ------------------ internal/equipmentimports/template.csv | 5 - sqlc.sqlite.yml | 10 - 22 files changed, 2034 deletions(-) delete mode 100644 cmd/web/handlers:equipment:export.go delete mode 100644 cmd/web/handlers:equipment:import.go delete mode 100644 cmd/web/handlers:equipment:import_test.go delete mode 100644 internal/database/migrations/sqlite/00020_equipment_imports.sql rename internal/database/migrations/sqlite/{20260823121431_import_sessions.sql => 00025_import_sessions.sql} (100%) rename internal/database/migrations/sqlite/{20260823121436_import_data.sql => 00026_import_data.sql} (100%) rename internal/database/migrations/sqlite/{20260823121445_import_mappings.sql => 00027_import_mappings.sql} (100%) delete mode 100644 internal/database/queries/gen/equipmentimports/db.go delete mode 100644 internal/database/queries/gen/equipmentimports/equipment_imports.sql.go delete mode 100644 internal/database/queries/gen/equipmentimports/models.go delete mode 100644 internal/database/queries/gen/equipmentimports/querier.go delete mode 100644 internal/database/queries/sqlite/equipment_imports.sql delete mode 100644 internal/equipmentimports/csv.go delete mode 100644 internal/equipmentimports/importer.go delete mode 100644 internal/equipmentimports/model.go delete mode 100644 internal/equipmentimports/repository.go delete mode 100644 internal/equipmentimports/rows.go delete mode 100644 internal/equipmentimports/service.go delete mode 100644 internal/equipmentimports/template.csv diff --git a/cmd/web/handlers:equipment:export.go b/cmd/web/handlers:equipment:export.go deleted file mode 100644 index b248ccf..0000000 --- a/cmd/web/handlers:equipment:export.go +++ /dev/null @@ -1,81 +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 main - -import ( - "encoding/csv" - "math" - "net/http" - - "github.com/bit8bytes/gearberg/internal/equipment" - "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 -// compile if RowsForItem is updated without updating ExpectedHeaders (or vice -// versa), catching column-count drift at build time. -const exportColumnCount = 26 - -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.List(ctx, equipment.ListParams{OrgID: orgID, Filters: pagination.Filters{Page: 1, PageSize: math.MaxInt32}}) - if err != nil { - return httperr.InternalServerError(err) - } - - mfrs, err := app.services.manufacturers.List(ctx, orgID) - if err != nil { - return httperr.InternalServerError(err) - } - mfrByID := make(map[string]string, len(mfrs)) - for _, m := range mfrs { - mfrByID[m.ID] = m.Name - } - - w.Header().Set("Content-Type", "text/csv; charset=utf-8") - w.Header().Set("Content-Disposition", `attachment; filename="gearberg-equipment-export.csv"`) - // UTF-8 BOM so Excel opens the file with correct encoding. - _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) - - cw := csv.NewWriter(w) - _ = cw.Write(equipmentimports.ExpectedHeaders) - - for _, item := range items { - if item.IsArchived || item.TotalStock == 0 { - continue - } - - var units []equipment.Unit - if item.TrackingType == tracking.Serialized { - units, err = app.services.equipment.ListUnits(ctx, item.ID) - if err != nil { - return httperr.InternalServerError(err) - } - } - - for _, row := range equipmentimports.RowsForItem(item, mfrByID[item.ManufacturerID], units) { - _ = cw.Write(row) - } - } - cw.Flush() - return nil -} diff --git a/cmd/web/handlers:equipment:import.go b/cmd/web/handlers:equipment:import.go deleted file mode 100644 index 0b643b2..0000000 --- a/cmd/web/handlers:equipment:import.go +++ /dev/null @@ -1,131 +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 main - -import ( - "net/http" - "net/url" - - "github.com/bit8bytes/gearberg/internal/equipmentimports" - "github.com/bit8bytes/gearberg/internal/httperr" - "github.com/bit8bytes/gearberg/internal/templates/pages" -) - -type equipmentImportData struct { - OrgID string - Error string -} - -type equipmentImportPreviewData struct { - OrgID string - ImportID string - Rows []equipmentimports.GroupedRow - CountNew int - CountError int -} - -// getEquipmentImport serves the upload form when no ?id= param is present, -// or the staging preview when ?id= is set (after a successful upload). -func (app *application) getEquipmentImport(w http.ResponseWriter, r *http.Request) *httperr.Error { - orgID := r.PathValue("org_id") - importID := r.URL.Query().Get("id") - if importID == "" { - data := app.html.TemplateData(r) - data.Data = equipmentImportData{OrgID: orgID} - return app.html.Render(w, r, http.StatusOK, pages.EquipmentImport, data) - } - return app.renderImportPreview(w, r, orgID, importID) -} - -func (app *application) postEquipmentImport(w http.ResponseWriter, r *http.Request) *httperr.Error { - ctx := r.Context() - orgID := r.PathValue("org_id") - - 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) - } - - const maxImportBytes = 32 << 20 // 32 MiB - if err := r.ParseMultipartForm(maxImportBytes); err != nil { //nolint:gosec // maxImportBytes is a bounded constant (32 MiB) - return fail("Could not parse form.") - } - - f, _, err := r.FormFile("file") - if err != nil { - return fail("No file uploaded.") - } - defer func() { _ = f.Close() }() - - rawRows, parseErr := equipmentimports.ParseCSV(f) - if parseErr != nil { - return fail(parseErr.Error()) - } - - importID, err := app.services.equipmentImports.Stage(ctx, orgID, rawRows) - if err != nil { - return httperr.InternalServerError(err) - } - - http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment/import?id="+url.QueryEscape(importID), http.StatusSeeOther) - return nil -} - -func (app *application) renderImportPreview(w http.ResponseWriter, r *http.Request, orgID, importID string) *httperr.Error { - ctx := r.Context() - - staged, err := app.services.equipmentImports.ListStaged(ctx, importID) - if err != nil { - return httperr.InternalServerError(err) - } - - previewRows, cntNew, cntError := equipmentimports.GroupRows(staged) - - data := app.html.TemplateData(r) - data.Data = equipmentImportPreviewData{ - OrgID: orgID, - ImportID: importID, - Rows: previewRows, - CountNew: cntNew, - CountError: cntError, - } - return app.html.Render(w, r, http.StatusOK, pages.EquipmentImportPreview, data) -} - -func (app *application) postEquipmentImportConfirm(w http.ResponseWriter, r *http.Request) *httperr.Error { - ctx := r.Context() - orgID := r.PathValue("org_id") - - if err := r.ParseForm(); err != nil { - return httperr.BadRequest(err) - } - importID := r.FormValue("import_id") - - if err := app.services.equipmentImports.Commit(ctx, importID, orgID); err != nil { - return httperr.InternalServerError(err) - } - - http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther) - return nil -} - -// getEquipmentImportTemplate serves a ready-to-fill CSV template for download. -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(equipmentimports.TemplateCSV) - return nil -} diff --git a/cmd/web/handlers:equipment:import_test.go b/cmd/web/handlers:equipment:import_test.go deleted file mode 100644 index ae4ace7..0000000 --- a/cmd/web/handlers:equipment:import_test.go +++ /dev/null @@ -1,134 +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 main - -import ( - "net/http" - "net/url" - "strings" - "testing" - - "github.com/bit8bytes/gearberg/internal/equipmentimports" -) - -func TestEquipmentImportExportRoundTrip(t *testing.T) { - alice.signin(t) - importID := roundTripUpload(t) - roundTripConfirm(t, importID) - body := roundTripExport(t) - alice.signout(t) - assertRoundTrip(t, body) -} - -func roundTripUpload(t *testing.T) string { - t.Helper() - 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) - } - return importIDFromLocation(t, header.Get("Location")) -} - -func roundTripConfirm(t *testing.T, importID string) { - t.Helper() - code, _, _ := ts.postForm(t, "/orgs/"+alice.orgID+"/equipment/import/confirm", url.Values{"import_id": {importID}}) - if code != http.StatusSeeOther { - t.Fatalf("confirm: want 303, got %d", code) - } -} - -func roundTripExport(t *testing.T) []byte { - t.Helper() - code, _, body := ts.get(t, "/orgs/"+alice.orgID+"/equipment/export") - if code != http.StatusOK { - t.Fatalf("export: want 200, got %d", code) - } - return body -} - -func assertRoundTrip(t *testing.T, body []byte) { - t.Helper() - rows := parseExportCSV(t, body) - assertExportCounts(t, rows) - assertShureQuantity(t, rows) - assertSonySerials(t, rows) -} - -func parseExportCSV(t *testing.T, body []byte) []equipmentimports.ProcessedRow { - 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 := equipmentimports.ParseCSV(strings.NewReader(string(body))) - if err != nil { - t.Fatalf("ParseCSV on export: %v", err) - } - return rows -} - -func assertExportCounts(t *testing.T, rows []equipmentimports.ProcessedRow) { - t.Helper() - counts := make(map[string]int) - for _, r := range rows { - counts[r.Data.Name]++ - } - if counts["Shure SM58"] != 1 { - t.Errorf("Shure SM58: want 1 export row, got %d", counts["Shure SM58"]) - } - if counts["Sony A7 IV"] != 2 { - t.Errorf("Sony A7 IV: want 2 export rows (one per unit), got %d", counts["Sony A7 IV"]) - } - if counts["Pelican 1510 Case"] != 1 { - t.Errorf("Pelican 1510 Case: want 1 export row, got %d", counts["Pelican 1510 Case"]) - } -} - -func assertShureQuantity(t *testing.T, rows []equipmentimports.ProcessedRow) { - t.Helper() - for _, r := range rows { - if r.Data.Name == "Shure SM58" && r.Data.Quantity != "7" { - t.Errorf("Shure SM58: want quantity 7, got %q", r.Data.Quantity) - } - } -} - -func assertSonySerials(t *testing.T, rows []equipmentimports.ProcessedRow) { - t.Helper() - serials := make(map[string]bool) - for _, r := range rows { - if r.Data.Name == "Sony A7 IV" { - serials[r.Data.UnitSerialNumber] = true - } - } - for _, want := range []string{"SN-A7IV-001", "SN-A7IV-002"} { - if !serials[want] { - t.Errorf("Sony A7 IV: serial %q not found in export", want) - } - } -} - -// importIDFromLocation extracts the ?id= query parameter from a redirect Location header. -func importIDFromLocation(t *testing.T, loc string) string { - t.Helper() - for part := range strings.SplitSeq(loc, "?") { - params, _ := url.ParseQuery(part) - if id := params.Get("id"); id != "" { - return id - } - } - t.Fatalf("redirect location %q contains no import id", loc) - return "" -} diff --git a/cmd/web/routes.go b/cmd/web/routes.go index 4609744..d960949 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -83,11 +83,6 @@ func (app *application) routes() (http.Handler, error) { // Equipment mux.Handle("GET /orgs/{org_id}/equipment", app.withLogin(app.withPermission(app.html.Handle(app.getEquipment)))) mux.Handle("GET /orgs/{org_id}/equipment/print", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPrint)))) - mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentExport)))) - mux.Handle("GET /orgs/{org_id}/equipment/import", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentImport)))) - mux.Handle("GET /orgs/{org_id}/equipment/import/template", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentImportTemplate)))) - mux.Handle("POST /orgs/{org_id}/equipment/import", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentImport)))) - mux.Handle("POST /orgs/{org_id}/equipment/import/confirm", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentImportConfirm)))) mux.Handle("GET /orgs/{org_id}/equipment/new", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentNew)))) mux.Handle("POST /orgs/{org_id}/equipment/new", app.withLogin(app.withPermission(app.withCheckQuota(app.html.Handle(app.postEquipmentNew))))) mux.Handle("GET /orgs/{org_id}/equipment/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentItem)))) diff --git a/cmd/web/setup.go b/cmd/web/setup.go index f326c6e..6037206 100644 --- a/cmd/web/setup.go +++ b/cmd/web/setup.go @@ -38,7 +38,6 @@ import ( "github.com/bit8bytes/gearberg/internal/database" "github.com/bit8bytes/gearberg/internal/database/migrations" "github.com/bit8bytes/gearberg/internal/equipment" - 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" @@ -233,7 +232,6 @@ type services struct { manufacturers *manufacturers.Service locations *locations.Service equipment *equipment.Service - equipmentImports *invimports.Service storageManager *storage.Manager } @@ -268,9 +266,6 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*s inventoryRepo := equipment.NewRepository(db) inventorySvc := equipment.NewService(inventoryRepo, db) - equipmentImportsRepo := invimports.NewRepository(db) - equipmentImportsSvc := invimports.NewService(equipmentImportsRepo, db, inventoryRepo, equipmentcategoriesSvc, manufacturersSvc, locationsSvc) - store, err := storage.Open("local", opts.StorageDSN, logger) if err != nil { return nil, fmt.Errorf("setupServices: open storage: %w", err) @@ -290,7 +285,6 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*s manufacturers: manufacturersSvc, locations: locationsSvc, equipment: inventorySvc, - equipmentImports: equipmentImportsSvc, storageManager: storageMgr, }, nil } diff --git a/internal/database/migrations/sqlite/00020_equipment_imports.sql b/internal/database/migrations/sqlite/00020_equipment_imports.sql deleted file mode 100644 index cc0d0b7..0000000 --- a/internal/database/migrations/sqlite/00020_equipment_imports.sql +++ /dev/null @@ -1,52 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE equipment_imports ( - id TEXT NOT NULL PRIMARY KEY, - import_id TEXT NOT NULL, - org_id TEXT NOT NULL, - row_number INTEGER NOT NULL, - status TEXT NOT NULL, - error_message TEXT NOT NULL DEFAULT '', - action TEXT NOT NULL DEFAULT 'create', - existing_equipment_id TEXT, - existing_item_id TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - -- raw CSV columns; all TEXT to preserve input before validation - name TEXT NOT NULL DEFAULT '', - -- Labels are defined by Gearberg and should match the seeded once - type_label TEXT NOT NULL DEFAULT '', -- Bulk/Serialized - tracking_label TEXT NOT NULL DEFAULT '', -- Physical/Virtual - usage_type_label TEXT NOT NULL DEFAULT '', -- Rental/Resale - -- User provided fields: - category_name TEXT NOT NULL DEFAULT '', - manufacturer_name TEXT NOT NULL DEFAULT '', - location_name TEXT NOT NULL DEFAULT '', - purchase_price TEXT NOT NULL DEFAULT '', - rental_price TEXT NOT NULL DEFAULT '', - resale_price TEXT NOT NULL DEFAULT '', - notes TEXT NOT NULL DEFAULT '', - weight_g TEXT NOT NULL DEFAULT '', - width_mm TEXT NOT NULL DEFAULT '', - height_mm TEXT NOT NULL DEFAULT '', - depth_mm TEXT NOT NULL DEFAULT '', - voltage_mv TEXT NOT NULL DEFAULT '', - current_ma TEXT NOT NULL DEFAULT '', - power_mw TEXT NOT NULL DEFAULT '', - wire_gauge_mm2_x100 TEXT NOT NULL DEFAULT '', - quantity TEXT NOT NULL DEFAULT '1', - equipment_type_label TEXT NOT NULL DEFAULT '', - -- Units for serialized equipment items - unit_serial_number TEXT NOT NULL DEFAULT '', - unit_manufacturer_serial TEXT NOT NULL DEFAULT '', - unit_purchase_price TEXT NOT NULL DEFAULT '', - unit_purchased_at TEXT NOT NULL DEFAULT '', - next_inspection_at TEXT NOT NULL DEFAULT '', - unit_is_active TEXT NOT NULL DEFAULT '1', - unit_remark TEXT NOT NULL DEFAULT '' -) STRICT; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE IF EXISTS equipment_imports; --- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/20260823121431_import_sessions.sql b/internal/database/migrations/sqlite/00025_import_sessions.sql similarity index 100% rename from internal/database/migrations/sqlite/20260823121431_import_sessions.sql rename to internal/database/migrations/sqlite/00025_import_sessions.sql diff --git a/internal/database/migrations/sqlite/20260823121436_import_data.sql b/internal/database/migrations/sqlite/00026_import_data.sql similarity index 100% rename from internal/database/migrations/sqlite/20260823121436_import_data.sql rename to internal/database/migrations/sqlite/00026_import_data.sql diff --git a/internal/database/migrations/sqlite/20260823121445_import_mappings.sql b/internal/database/migrations/sqlite/00027_import_mappings.sql similarity index 100% rename from internal/database/migrations/sqlite/20260823121445_import_mappings.sql rename to internal/database/migrations/sqlite/00027_import_mappings.sql diff --git a/internal/database/queries/gen/equipmentimports/db.go b/internal/database/queries/gen/equipmentimports/db.go deleted file mode 100644 index 6d8a203..0000000 --- a/internal/database/queries/gen/equipmentimports/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package equipmentimports - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/internal/database/queries/gen/equipmentimports/equipment_imports.sql.go b/internal/database/queries/gen/equipmentimports/equipment_imports.sql.go deleted file mode 100644 index 38bbbd0..0000000 --- a/internal/database/queries/gen/equipmentimports/equipment_imports.sql.go +++ /dev/null @@ -1,344 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: equipment_imports.sql - -package equipmentimports - -import ( - "context" - "database/sql" -) - -const deleteImportsByImportID = `-- name: DeleteImportsByImportID :exec -DELETE FROM equipment_imports WHERE import_id = ? -` - -func (q *Queries) DeleteImportsByImportID(ctx context.Context, importID string) error { - _, err := q.db.ExecContext(ctx, deleteImportsByImportID, importID) - return err -} - -const deleteImportsByOrgID = `-- name: DeleteImportsByOrgID :exec -DELETE FROM equipment_imports WHERE org_id = ? -` - -func (q *Queries) DeleteImportsByOrgID(ctx context.Context, orgID string) error { - _, err := q.db.ExecContext(ctx, deleteImportsByOrgID, orgID) - return err -} - -const getImportRow = `-- name: GetImportRow :one -SELECT id, import_id, org_id, row_number, status, error_message, "action", existing_equipment_id, existing_item_id, created_at, name, type_label, tracking_label, usage_type_label, category_name, manufacturer_name, location_name, purchase_price, rental_price, resale_price, notes, weight_g, width_mm, height_mm, depth_mm, voltage_mv, current_ma, power_mw, wire_gauge_mm2_x100, quantity, equipment_type_label, unit_serial_number, unit_manufacturer_serial, unit_purchase_price, unit_purchased_at, next_inspection_at, unit_is_active, unit_remark FROM equipment_imports WHERE id = ? -` - -func (q *Queries) GetImportRow(ctx context.Context, id string) (EquipmentImport, error) { - row := q.db.QueryRowContext(ctx, getImportRow, id) - var i EquipmentImport - err := row.Scan( - &i.ID, - &i.ImportID, - &i.OrgID, - &i.RowNumber, - &i.Status, - &i.ErrorMessage, - &i.Action, - &i.ExistingEquipmentID, - &i.ExistingItemID, - &i.CreatedAt, - &i.Name, - &i.TypeLabel, - &i.TrackingLabel, - &i.UsageTypeLabel, - &i.CategoryName, - &i.ManufacturerName, - &i.LocationName, - &i.PurchasePrice, - &i.RentalPrice, - &i.ResalePrice, - &i.Notes, - &i.WeightG, - &i.WidthMm, - &i.HeightMm, - &i.DepthMm, - &i.VoltageMv, - &i.CurrentMa, - &i.PowerMw, - &i.WireGaugeMm2X100, - &i.Quantity, - &i.EquipmentTypeLabel, - &i.UnitSerialNumber, - &i.UnitManufacturerSerial, - &i.UnitPurchasePrice, - &i.UnitPurchasedAt, - &i.NextInspectionAt, - &i.UnitIsActive, - &i.UnitRemark, - ) - return i, err -} - -const insertImportRow = `-- name: InsertImportRow :one -INSERT INTO equipment_imports ( - id, - import_id, - org_id, - row_number, - status, - error_message, - action, - existing_equipment_id, - existing_item_id, - name, - type_label, - tracking_label, - usage_type_label, - category_name, - manufacturer_name, - location_name, - purchase_price, - rental_price, - resale_price, - notes, - weight_g, - width_mm, - height_mm, - depth_mm, - voltage_mv, - current_ma, - power_mw, - wire_gauge_mm2_x100, - quantity, - equipment_type_label, - unit_serial_number, - unit_manufacturer_serial, - unit_purchase_price, - unit_purchased_at, - next_inspection_at, - unit_is_active, - unit_remark -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -) RETURNING id, import_id, org_id, row_number, status, error_message, "action", existing_equipment_id, existing_item_id, created_at, name, type_label, tracking_label, usage_type_label, category_name, manufacturer_name, location_name, purchase_price, rental_price, resale_price, notes, weight_g, width_mm, height_mm, depth_mm, voltage_mv, current_ma, power_mw, wire_gauge_mm2_x100, quantity, equipment_type_label, unit_serial_number, unit_manufacturer_serial, unit_purchase_price, unit_purchased_at, next_inspection_at, unit_is_active, unit_remark -` - -type InsertImportRowParams struct { - ID string - ImportID string - OrgID string - RowNumber int64 - Status string - ErrorMessage string - Action string - ExistingEquipmentID sql.NullString - ExistingItemID sql.NullString - Name string - TypeLabel string - TrackingLabel string - UsageTypeLabel string - CategoryName string - ManufacturerName string - LocationName string - PurchasePrice string - RentalPrice string - ResalePrice string - Notes string - WeightG string - WidthMm string - HeightMm string - DepthMm string - VoltageMv string - CurrentMa string - PowerMw string - WireGaugeMm2X100 string - Quantity string - EquipmentTypeLabel string - UnitSerialNumber string - UnitManufacturerSerial string - UnitPurchasePrice string - UnitPurchasedAt string - NextInspectionAt string - UnitIsActive string - UnitRemark string -} - -func (q *Queries) InsertImportRow(ctx context.Context, arg InsertImportRowParams) (EquipmentImport, error) { - row := q.db.QueryRowContext(ctx, insertImportRow, - arg.ID, - arg.ImportID, - arg.OrgID, - arg.RowNumber, - arg.Status, - arg.ErrorMessage, - arg.Action, - arg.ExistingEquipmentID, - arg.ExistingItemID, - arg.Name, - arg.TypeLabel, - arg.TrackingLabel, - arg.UsageTypeLabel, - arg.CategoryName, - arg.ManufacturerName, - arg.LocationName, - arg.PurchasePrice, - arg.RentalPrice, - arg.ResalePrice, - arg.Notes, - arg.WeightG, - arg.WidthMm, - arg.HeightMm, - arg.DepthMm, - arg.VoltageMv, - arg.CurrentMa, - arg.PowerMw, - arg.WireGaugeMm2X100, - arg.Quantity, - arg.EquipmentTypeLabel, - arg.UnitSerialNumber, - arg.UnitManufacturerSerial, - arg.UnitPurchasePrice, - arg.UnitPurchasedAt, - arg.NextInspectionAt, - arg.UnitIsActive, - arg.UnitRemark, - ) - var i EquipmentImport - err := row.Scan( - &i.ID, - &i.ImportID, - &i.OrgID, - &i.RowNumber, - &i.Status, - &i.ErrorMessage, - &i.Action, - &i.ExistingEquipmentID, - &i.ExistingItemID, - &i.CreatedAt, - &i.Name, - &i.TypeLabel, - &i.TrackingLabel, - &i.UsageTypeLabel, - &i.CategoryName, - &i.ManufacturerName, - &i.LocationName, - &i.PurchasePrice, - &i.RentalPrice, - &i.ResalePrice, - &i.Notes, - &i.WeightG, - &i.WidthMm, - &i.HeightMm, - &i.DepthMm, - &i.VoltageMv, - &i.CurrentMa, - &i.PowerMw, - &i.WireGaugeMm2X100, - &i.Quantity, - &i.EquipmentTypeLabel, - &i.UnitSerialNumber, - &i.UnitManufacturerSerial, - &i.UnitPurchasePrice, - &i.UnitPurchasedAt, - &i.NextInspectionAt, - &i.UnitIsActive, - &i.UnitRemark, - ) - return i, err -} - -const listImportRowsByImportID = `-- name: ListImportRowsByImportID :many -SELECT id, import_id, org_id, row_number, status, error_message, "action", existing_equipment_id, existing_item_id, created_at, name, type_label, tracking_label, usage_type_label, category_name, manufacturer_name, location_name, purchase_price, rental_price, resale_price, notes, weight_g, width_mm, height_mm, depth_mm, voltage_mv, current_ma, power_mw, wire_gauge_mm2_x100, quantity, equipment_type_label, unit_serial_number, unit_manufacturer_serial, unit_purchase_price, unit_purchased_at, next_inspection_at, unit_is_active, unit_remark FROM equipment_imports WHERE import_id = ? ORDER BY row_number ASC -` - -func (q *Queries) ListImportRowsByImportID(ctx context.Context, importID string) ([]EquipmentImport, error) { - rows, err := q.db.QueryContext(ctx, listImportRowsByImportID, importID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []EquipmentImport - for rows.Next() { - var i EquipmentImport - if err := rows.Scan( - &i.ID, - &i.ImportID, - &i.OrgID, - &i.RowNumber, - &i.Status, - &i.ErrorMessage, - &i.Action, - &i.ExistingEquipmentID, - &i.ExistingItemID, - &i.CreatedAt, - &i.Name, - &i.TypeLabel, - &i.TrackingLabel, - &i.UsageTypeLabel, - &i.CategoryName, - &i.ManufacturerName, - &i.LocationName, - &i.PurchasePrice, - &i.RentalPrice, - &i.ResalePrice, - &i.Notes, - &i.WeightG, - &i.WidthMm, - &i.HeightMm, - &i.DepthMm, - &i.VoltageMv, - &i.CurrentMa, - &i.PowerMw, - &i.WireGaugeMm2X100, - &i.Quantity, - &i.EquipmentTypeLabel, - &i.UnitSerialNumber, - &i.UnitManufacturerSerial, - &i.UnitPurchasePrice, - &i.UnitPurchasedAt, - &i.NextInspectionAt, - &i.UnitIsActive, - &i.UnitRemark, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const updateImportRowAction = `-- name: UpdateImportRowAction :exec -UPDATE equipment_imports SET action = ? WHERE id = ? -` - -type UpdateImportRowActionParams struct { - Action string - ID string -} - -func (q *Queries) UpdateImportRowAction(ctx context.Context, arg UpdateImportRowActionParams) error { - _, err := q.db.ExecContext(ctx, updateImportRowAction, arg.Action, arg.ID) - return err -} - -const updateImportRowSerialNumber = `-- name: UpdateImportRowSerialNumber :exec -UPDATE equipment_imports SET unit_serial_number = ? WHERE id = ? -` - -type UpdateImportRowSerialNumberParams struct { - UnitSerialNumber string - ID string -} - -func (q *Queries) UpdateImportRowSerialNumber(ctx context.Context, arg UpdateImportRowSerialNumberParams) error { - _, err := q.db.ExecContext(ctx, updateImportRowSerialNumber, arg.UnitSerialNumber, arg.ID) - return err -} diff --git a/internal/database/queries/gen/equipmentimports/models.go b/internal/database/queries/gen/equipmentimports/models.go deleted file mode 100644 index 5f0f453..0000000 --- a/internal/database/queries/gen/equipmentimports/models.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package equipmentimports - -import ( - "database/sql" -) - -type EquipmentImport struct { - ID string - ImportID string - OrgID string - RowNumber int64 - Status string - ErrorMessage string - Action string - ExistingEquipmentID sql.NullString - ExistingItemID sql.NullString - CreatedAt int64 - Name string - TypeLabel string - TrackingLabel string - UsageTypeLabel string - CategoryName string - ManufacturerName string - LocationName string - PurchasePrice string - RentalPrice string - ResalePrice string - Notes string - WeightG string - WidthMm string - HeightMm string - DepthMm string - VoltageMv string - CurrentMa string - PowerMw string - WireGaugeMm2X100 string - Quantity string - EquipmentTypeLabel string - UnitSerialNumber string - UnitManufacturerSerial string - UnitPurchasePrice string - UnitPurchasedAt string - NextInspectionAt string - UnitIsActive string - UnitRemark string -} diff --git a/internal/database/queries/gen/equipmentimports/querier.go b/internal/database/queries/gen/equipmentimports/querier.go deleted file mode 100644 index 0dd37e6..0000000 --- a/internal/database/queries/gen/equipmentimports/querier.go +++ /dev/null @@ -1,21 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 - -package equipmentimports - -import ( - "context" -) - -type Querier interface { - DeleteImportsByImportID(ctx context.Context, importID string) error - DeleteImportsByOrgID(ctx context.Context, orgID string) error - GetImportRow(ctx context.Context, id string) (EquipmentImport, error) - InsertImportRow(ctx context.Context, arg InsertImportRowParams) (EquipmentImport, error) - ListImportRowsByImportID(ctx context.Context, importID string) ([]EquipmentImport, error) - UpdateImportRowAction(ctx context.Context, arg UpdateImportRowActionParams) error - UpdateImportRowSerialNumber(ctx context.Context, arg UpdateImportRowSerialNumberParams) error -} - -var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/sqlite/equipment_imports.sql b/internal/database/queries/sqlite/equipment_imports.sql deleted file mode 100644 index 1903e3a..0000000 --- a/internal/database/queries/sqlite/equipment_imports.sql +++ /dev/null @@ -1,63 +0,0 @@ --- name: InsertImportRow :one -INSERT INTO equipment_imports ( - id, - import_id, - org_id, - row_number, - status, - error_message, - action, - existing_equipment_id, - existing_item_id, - name, - type_label, - tracking_label, - usage_type_label, - category_name, - manufacturer_name, - location_name, - purchase_price, - rental_price, - resale_price, - notes, - weight_g, - width_mm, - height_mm, - depth_mm, - voltage_mv, - current_ma, - power_mw, - wire_gauge_mm2_x100, - quantity, - equipment_type_label, - unit_serial_number, - unit_manufacturer_serial, - unit_purchase_price, - unit_purchased_at, - next_inspection_at, - unit_is_active, - unit_remark -) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ? -) RETURNING *; - --- name: DeleteImportsByOrgID :exec -DELETE FROM equipment_imports WHERE org_id = ?; - --- name: DeleteImportsByImportID :exec -DELETE FROM equipment_imports WHERE import_id = ?; - --- name: ListImportRowsByImportID :many -SELECT * FROM equipment_imports WHERE import_id = ? ORDER BY row_number ASC; - --- name: GetImportRow :one -SELECT * FROM equipment_imports WHERE id = ?; - --- name: UpdateImportRowAction :exec -UPDATE equipment_imports SET action = ? WHERE id = ?; - --- name: UpdateImportRowSerialNumber :exec -UPDATE equipment_imports SET unit_serial_number = ? WHERE id = ?; diff --git a/internal/equipmentimports/csv.go b/internal/equipmentimports/csv.go deleted file mode 100644 index c134769..0000000 --- a/internal/equipmentimports/csv.go +++ /dev/null @@ -1,65 +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 equipmentimports - -import ( - "context" - "fmt" - "io" - "strings" - - pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" -) - -// columnAliases maps legacy column names to their current canonical name. -// Old exports that used a different name for a column are accepted transparently. -var columnAliases = map[string]string{ - // "Has Content" was a boolean column (TRUE/FALSE) replaced by "Equipment Type" - // (Standard/Kit). Values are normalised in MapRecords. - "Has Content": "Equipment Type", -} - -// ParseCSV reads a CSV (with or without a UTF-8 BOM) and returns processed rows -// with all values already converted to DB units (cents, grams, millimetres, etc.). -// All columns in ExpectedHeaders must be present; order and extra columns are ignored. -// Every row is initialised to StateValid; ImportID and OrgID are left empty — -// Stage sets them before persisting. -func ParseCSV(r io.Reader) ([]ProcessedRow, error) { - rd := &pkgcsv.Reader{Aliases: columnAliases} - records, err := rd.Read(context.Background(), r) - if err != nil { - return nil, fmt.Errorf("ParseCSV: %w", err) - } - for _, name := range ExpectedHeaders { - if _, ok := records[0].Fields[name]; !ok { - return nil, fmt.Errorf("ParseCSV: missing required column %q", name) - } - } - return MapRecords(records, "", ""), nil -} - -// normalizeEquipmentTypeLabel maps legacy boolean values from the old "Has Content" -// column to the current Equipment Type labels used by TypeFromString. -func normalizeEquipmentTypeLabel(v string) string { - switch strings.ToUpper(strings.TrimSpace(v)) { - case "TRUE", "1": - return "Kit" - case "FALSE", "0": - return "Standard" - default: - return v - } -} diff --git a/internal/equipmentimports/importer.go b/internal/equipmentimports/importer.go deleted file mode 100644 index 96b18d9..0000000 --- a/internal/equipmentimports/importer.go +++ /dev/null @@ -1,119 +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 equipmentimports - -import ( - "context" - "fmt" - "io" - - "github.com/bit8bytes/gearberg/internal/units" - pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" -) - -// dbStr converts a *T (any int64-backed unit type) to its raw integer string, -// or "" if nil. Used to store DB-unit values in Row string fields. -func dbStr[T ~int64](v *T) string { - if v == nil { - return "" - } - return fmt.Sprintf("%d", int64(*v)) -} - -// Inspector reads only the header layer of a file to discover column names for -// the field-mapping UI step, without parsing the full dataset. -type Inspector interface { - InspectHeaders(ctx context.Context, r io.Reader) ([]string, error) -} - -// Reader parses an input source into records. -// Satisfied by *csv.Reader, or a future *json.Reader. -type Reader interface { - Read(ctx context.Context, r io.Reader) ([]pkgcsv.Record, error) -} - -// Step is a single pipeline stage applied to a batch of ProcessedRows. -// Steps are composable and run in order via RunValidation. -// A Step must never return early on a per-row error; instead it mutates -// the row's State to StateInvalid and appends to its Errors slice. -type Step func(ctx context.Context, rows []ProcessedRow) ([]ProcessedRow, error) - -// Writer commits a processed batch to a destination. -// Close must be called after Write to release any held resources. -type Writer interface { - Write(ctx context.Context, rows []Row) error - Close() error -} - -// RunValidation applies steps in order to rows, collecting validation errors -// per row without aborting early. Returns the annotated batch. -func RunValidation(ctx context.Context, rows []ProcessedRow, steps []Step) ([]ProcessedRow, error) { - var err error - for _, step := range steps { - rows, err = step(ctx, rows) - if err != nil { - return nil, err - } - } - return rows, nil -} - -// MapRecords converts pkg/csv records into ProcessedRow values with all physical -// and monetary quantities already expressed in DB units (cents, grams, millimetres, -// millivolts, milliamps, milliwatts). Every row is initialised to StateValid; -// subsequent Steps mark rows invalid when constraints are violated. -// The caller supplies importID and orgID which are set on every row; IDs and -// status fields are left zero so Stage can assign them. -func MapRecords(records []pkgcsv.Record, importID, orgID string) []ProcessedRow { - rows := make([]ProcessedRow, 0, len(records)) - for _, rec := range records { - f := func(name string) string { return rec.Fields[name] } - rows = append(rows, ProcessedRow{ - State: StateValid, - Data: Row{ - ImportID: importID, - OrgID: orgID, - Name: f("Name"), - TypeLabel: f("Type"), - UsageTypeLabel: f("Usage"), - CategoryName: f("Category"), - ManufacturerName: f("Manufacturer"), - LocationName: f("Location"), - RentalPrice: dbStr(units.ParseCents(f("Rental Price"))), - ResalePrice: dbStr(units.ParseCents(f("Resale Price"))), - Notes: f("Notes"), - WeightG: dbStr(units.ParseGrams(f("Weight (kg)"))), - WidthMm: dbStr(units.ParseMillimeters(f("Width (cm)"))), - HeightMm: dbStr(units.ParseMillimeters(f("Height (cm)"))), - DepthMm: dbStr(units.ParseMillimeters(f("Depth (cm)"))), - VoltageMv: dbStr(units.ParseVolts(f("Voltage (V)"))), - CurrentMa: dbStr(units.ParseMilliamps(f("Current (A)"))), - PowerMw: dbStr(units.ParseMilliwatts(f("Power (W)"))), - WireGaugeMM2X100: dbStr(units.ParseWireGauge(f("Wire Gauge (mm² ×100)"))), - Quantity: f("Quantity"), - EquipmentTypeLabel: normalizeEquipmentTypeLabel(f("Equipment Type")), - UnitSerialNumber: f("Unit Serial Number"), - UnitManufacturerSerial: f("Unit Manufacturer Serial"), - UnitPurchasePrice: dbStr(units.ParseCents(f("Unit Purchase Price"))), - UnitPurchasedAt: f("Unit Purchased At"), - NextInspectionAt: f("Next Inspection At"), - UnitIsActive: f("Unit Active"), - UnitRemark: f("Unit Remark"), - }, - }) - } - return rows -} diff --git a/internal/equipmentimports/model.go b/internal/equipmentimports/model.go deleted file mode 100644 index b6123e8..0000000 --- a/internal/equipmentimports/model.go +++ /dev/null @@ -1,201 +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 equipmentimports handles CSV import staging and commit for inventory items. -package equipmentimports - -import ( - _ "embed" - "fmt" - "strings" -) - -// Status values for a staged import row. -const ( - StatusNew = "new" - StatusError = "error" -) - -// Action values for a staged import row. -const ( - ActionCreate = "create" - ActionSkip = "skip" -) - -// Row is a staged import row persisted in equipment_imports. -type Row struct { - ID string - ImportID string - OrgID string - RowNumber int64 - Status string - ErrorMessage string - Action string - ExistingEquipmentID *string - ExistingItemID *string - CreatedAt int64 - // Equipment fields - Name string - TypeLabel string - TrackingLabel string - UsageTypeLabel string - CategoryName string - ManufacturerName string - LocationName string - PurchasePrice string // equipment-level purchase price (reserved; not yet in CSV) - RentalPrice string - ResalePrice string - Notes string - WeightG string - WidthMm string - HeightMm string - DepthMm string - VoltageMv string - CurrentMa string - PowerMw string - WireGaugeMM2X100 string - Quantity string - EquipmentTypeLabel string - // Unit fields (serialized items only) - UnitSerialNumber string - UnitManufacturerSerial string - UnitPurchasePrice string - UnitPurchasedAt string - NextInspectionAt string - UnitIsActive string - UnitRemark string -} - -// GroupedRow is a display-oriented view of staged rows collapsed by equipment name. -// Serialized rows that share a name are folded into one entry; Stock holds the -// unit count for serialized items and the quantity string for bulk. -type GroupedRow struct { - RowNumber int64 - Name string - TypeLabel string - CategoryName string - Stock string - Status string - ErrorMessage string -} - -// GroupRows collapses serialized staging rows that share a name into one GroupedRow -// and returns per-item counts for new and error items. -func GroupRows(staged []Row) (rows []GroupedRow, cntNew, cntError int) { - type group struct { - row GroupedRow - total int - hasErr bool - } - seen := make(map[string]*group) - var order []string - - for _, r := range staged { - if !strings.EqualFold(r.TypeLabel, "serialized") { - rows = append(rows, GroupedRow{ - RowNumber: r.RowNumber, - Name: r.Name, - TypeLabel: r.TypeLabel, - CategoryName: r.CategoryName, - Stock: r.Quantity, - Status: r.Status, - ErrorMessage: r.ErrorMessage, - }) - if r.Status == StatusNew { - cntNew++ - } else { - cntError++ - } - continue - } - - key := strings.ToLower(r.Name) - if _, ok := seen[key]; !ok { - seen[key] = &group{row: GroupedRow{ - RowNumber: r.RowNumber, - Name: r.Name, - TypeLabel: r.TypeLabel, - CategoryName: r.CategoryName, - Status: StatusNew, - }} - order = append(order, key) - } - g := seen[key] - g.total++ - if r.Status == StatusError && !g.hasErr { - g.hasErr = true - g.row.Status = StatusError - g.row.ErrorMessage = r.ErrorMessage - } - } - - for _, key := range order { - g := seen[key] - g.row.Stock = fmt.Sprintf("%d", g.total) - rows = append(rows, g.row) - if g.hasErr { - cntError++ - } else { - cntNew++ - } - } - return -} - -// Mapping links user-defined source column names to canonical Row field names. -// Key: header from the uploaded file; Value: Row field name. -type Mapping map[string]string - -// RowState classifies a ProcessedRow for the UI and the commit gate. -type RowState string - -const ( - // StateValid marks a row that passed all validation checks and is safe to commit. - StateValid RowState = "valid" - - // StateInvalid marks a row that failed one or more validation checks and must not be committed. - StateInvalid RowState = "invalid" -) - -// ValidationError is a structured, field-level error message surfaced to the UI. -type ValidationError struct { - Line int `json:"line"` - Field string `json:"field"` - Reason string `json:"reason"` -} - -// ProcessedRow wraps a Row with its validation state and collected errors. -// The pipeline operates on []ProcessedRow; Stage converts back to []Row for storage. -type ProcessedRow struct { - State RowState - Errors []ValidationError - Data Row -} - -// TemplateCSV is the pre-filled example CSV file served to users as a download template. -// -//go:embed template.csv -var TemplateCSV []byte - -// ExpectedHeaders are the exact column headers the import CSV must have. -var ExpectedHeaders = []string{ - "Name", "Type", "Usage", "Category", "Manufacturer", "Location", - "Rental Price", "Resale Price", "Notes", - "Weight (kg)", "Width (cm)", "Height (cm)", "Depth (cm)", "Voltage (V)", "Current (A)", "Power (W)", - "Wire Gauge (mm² ×100)", - "Quantity", "Equipment Type", - "Unit Serial Number", "Unit Manufacturer Serial", "Unit Purchase Price", - "Unit Purchased At", "Next Inspection At", "Unit Active", "Unit Remark", -} diff --git a/internal/equipmentimports/repository.go b/internal/equipmentimports/repository.go deleted file mode 100644 index 61cc74e..0000000 --- a/internal/equipmentimports/repository.go +++ /dev/null @@ -1,177 +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 equipmentimports provides imports functionality. -package equipmentimports - -import ( - "context" - "database/sql" - "fmt" - - "github.com/bit8bytes/gearberg/internal/database" - genimports "github.com/bit8bytes/gearberg/internal/database/queries/gen/equipmentimports" -) - -// Repository provides data access for import staging rows. -type Repository struct { - q genimports.Querier -} - -// NewRepository returns a new Repository. -func NewRepository(db *sql.DB) *Repository { - return &Repository{q: genimports.New(db)} -} - -// Create inserts a staged import row. -func (r *Repository) Create(ctx context.Context, row Row) (*Row, error) { - rec, err := r.q.InsertImportRow(ctx, genimports.InsertImportRowParams{ - ID: row.ID, - ImportID: row.ImportID, - OrgID: row.OrgID, - RowNumber: row.RowNumber, - Status: row.Status, - ErrorMessage: row.ErrorMessage, - Action: row.Action, - ExistingEquipmentID: database.NullString(row.ExistingEquipmentID), - ExistingItemID: database.NullString(row.ExistingItemID), - Name: row.Name, - TypeLabel: row.TypeLabel, - TrackingLabel: row.TrackingLabel, - UsageTypeLabel: row.UsageTypeLabel, - CategoryName: row.CategoryName, - ManufacturerName: row.ManufacturerName, - LocationName: row.LocationName, - PurchasePrice: row.PurchasePrice, - RentalPrice: row.RentalPrice, - ResalePrice: row.ResalePrice, - Notes: row.Notes, - WeightG: row.WeightG, - WidthMm: row.WidthMm, - HeightMm: row.HeightMm, - DepthMm: row.DepthMm, - VoltageMv: row.VoltageMv, - CurrentMa: row.CurrentMa, - PowerMw: row.PowerMw, - WireGaugeMm2X100: row.WireGaugeMM2X100, - Quantity: row.Quantity, - EquipmentTypeLabel: row.EquipmentTypeLabel, - UnitSerialNumber: row.UnitSerialNumber, - UnitManufacturerSerial: row.UnitManufacturerSerial, - UnitPurchasePrice: row.UnitPurchasePrice, - UnitPurchasedAt: row.UnitPurchasedAt, - NextInspectionAt: row.NextInspectionAt, - UnitIsActive: row.UnitIsActive, - UnitRemark: row.UnitRemark, - }) - if err != nil { - return nil, fmt.Errorf("Create: %w", err) - } - out := fromRecord(rec) - return &out, nil -} - -// Get returns a single staged import row. -func (r *Repository) Get(ctx context.Context, id string) (*Row, error) { - rec, err := r.q.GetImportRow(ctx, id) - if err != nil { - return nil, fmt.Errorf("Get: %w", err) - } - out := fromRecord(rec) - return &out, nil -} - -// UpdateAction updates the action field of a staged import row. -func (r *Repository) UpdateAction(ctx context.Context, id, action string) error { - if err := r.q.UpdateImportRowAction(ctx, genimports.UpdateImportRowActionParams{ - ID: id, - Action: action, - }); err != nil { - return fmt.Errorf("UpdateAction: %w", err) - } - return nil -} - -// DeleteByOrgID deletes all staging rows for the org. -func (r *Repository) DeleteByOrgID(ctx context.Context, orgID string) error { - if err := r.q.DeleteImportsByOrgID(ctx, orgID); err != nil { - return fmt.Errorf("DeleteByOrgID: %w", err) - } - return nil -} - -// Delete deletes all staging rows for a specific import. -func (r *Repository) Delete(ctx context.Context, importID string) error { - if err := r.q.DeleteImportsByImportID(ctx, importID); err != nil { - return fmt.Errorf("Delete: %w", err) - } - return nil -} - -// List returns all staged rows for an import, ordered by row_number. -func (r *Repository) List(ctx context.Context, importID string) ([]Row, error) { - recs, err := r.q.ListImportRowsByImportID(ctx, importID) - if err != nil { - return nil, fmt.Errorf("List: %w", err) - } - rows := make([]Row, len(recs)) - for i, rec := range recs { - rows[i] = fromRecord(rec) - } - return rows, nil -} - -func fromRecord(rec genimports.EquipmentImport) Row { - return Row{ - ID: rec.ID, - ImportID: rec.ImportID, - OrgID: rec.OrgID, - RowNumber: rec.RowNumber, - Status: rec.Status, - ErrorMessage: rec.ErrorMessage, - Action: rec.Action, - ExistingEquipmentID: database.StringPtr(rec.ExistingEquipmentID), - ExistingItemID: database.StringPtr(rec.ExistingItemID), - CreatedAt: rec.CreatedAt, - Name: rec.Name, - TypeLabel: rec.TypeLabel, - TrackingLabel: rec.TrackingLabel, - UsageTypeLabel: rec.UsageTypeLabel, - CategoryName: rec.CategoryName, - ManufacturerName: rec.ManufacturerName, - LocationName: rec.LocationName, - PurchasePrice: rec.PurchasePrice, - RentalPrice: rec.RentalPrice, - ResalePrice: rec.ResalePrice, - Notes: rec.Notes, - WeightG: rec.WeightG, - WidthMm: rec.WidthMm, - HeightMm: rec.HeightMm, - DepthMm: rec.DepthMm, - VoltageMv: rec.VoltageMv, - CurrentMa: rec.CurrentMa, - PowerMw: rec.PowerMw, - WireGaugeMM2X100: rec.WireGaugeMm2X100, - Quantity: rec.Quantity, - EquipmentTypeLabel: rec.EquipmentTypeLabel, - UnitSerialNumber: rec.UnitSerialNumber, - UnitManufacturerSerial: rec.UnitManufacturerSerial, - UnitPurchasePrice: rec.UnitPurchasePrice, - UnitPurchasedAt: rec.UnitPurchasedAt, - NextInspectionAt: rec.NextInspectionAt, - UnitIsActive: rec.UnitIsActive, - UnitRemark: rec.UnitRemark, - } -} diff --git a/internal/equipmentimports/rows.go b/internal/equipmentimports/rows.go deleted file mode 100644 index 3a98f7f..0000000 --- a/internal/equipmentimports/rows.go +++ /dev/null @@ -1,103 +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 equipmentimports provides imports functionality. -package equipmentimports - -import ( - "strconv" - "time" - - "github.com/bit8bytes/gearberg/internal/equipment" - "github.com/bit8bytes/gearberg/internal/equipment/tracking" -) - -// headerIndex maps each ExpectedHeaders column name to its position. -// Built once at package init so RowsForItem can write by name, not by magic number. -var headerIndex = func() map[string]int { - m := make(map[string]int, len(ExpectedHeaders)) - for i, name := range ExpectedHeaders { - m[name] = i - } - return m -}() - -func newRow(base []string) []string { - row := make([]string, len(ExpectedHeaders)) - copy(row, base) - return row -} - -// RowsForItem returns the CSV data rows for one equipment item using the column -// order defined by ExpectedHeaders. -// Bulk items produce a single row; serialized items produce one row per unit. -func RowsForItem(item equipment.Equipment, mfrName string, units []equipment.Unit) [][]string { - h := headerIndex - base := make([]string, len(ExpectedHeaders)) - base[h["Name"]] = item.Name - base[h["Type"]] = item.TrackingType.Label() - base[h["Usage"]] = item.UsageType.Label() - base[h["Category"]] = item.CategoryName - base[h["Manufacturer"]] = mfrName - base[h["Location"]] = item.LocationName - base[h["Rental Price"]] = item.Pricing.RentalPrice.String() - base[h["Resale Price"]] = item.Pricing.PurchasePrice.String() - base[h["Notes"]] = item.Notes - base[h["Weight (kg)"]] = item.Properties.Weight.String() - base[h["Width (cm)"]] = item.Properties.Width.String() - base[h["Height (cm)"]] = item.Properties.Height.String() - base[h["Depth (cm)"]] = item.Properties.Depth.String() - base[h["Voltage (V)"]] = item.Properties.Voltage.String() - base[h["Current (A)"]] = item.Properties.Current.String() - base[h["Power (W)"]] = item.Properties.Power.String() - base[h["Wire Gauge (mm² ×100)"]] = item.Properties.WireGauge.String() - base[h["Equipment Type"]] = item.Type.Label() - - if item.TrackingType != tracking.Serialized { - row := newRow(base) - row[h["Quantity"]] = strconv.FormatInt(item.TotalStock, 10) - return [][]string{row} - } - - rows := make([][]string, 0, len(units)) - for _, u := range units { - row := newRow(base) - row[h["Unit Serial Number"]] = u.SerialNumber - row[h["Unit Manufacturer Serial"]] = u.ManufacturerSerialNumber - row[h["Unit Purchase Price"]] = u.PurchasePrice.String() - row[h["Unit Purchased At"]] = formatExportDate(u.PurchasedAt) - row[h["Next Inspection At"]] = formatExportDate(u.NextInspectionAt) - row[h["Unit Active"]] = formatExportActive(u.IsActive()) - row[h["Unit Remark"]] = u.Remark - rows = append(rows, row) - } - return rows -} - -// formatExportDate formats a Unix timestamp pointer as YYYY-MM-DD, or "" if nil. -func formatExportDate(ts *int64) string { - if ts == nil { - return "" - } - return time.Unix(*ts, 0).UTC().Format("2006-01-02") -} - -// formatExportActive returns "TRUE" for active, "FALSE" for inactive. -func formatExportActive(active bool) string { - if active { - return "TRUE" - } - return "FALSE" -} diff --git a/internal/equipmentimports/service.go b/internal/equipmentimports/service.go deleted file mode 100644 index 8e0a860..0000000 --- a/internal/equipmentimports/service.go +++ /dev/null @@ -1,436 +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 equipmentimports provides imports functionality. -package equipmentimports - -import ( - "context" - "database/sql" - "fmt" - "math" - "strconv" - "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/bit8bytes/gearberg/internal/uid" - "github.com/bit8bytes/gearberg/internal/units" -) - -// Upserter resolves or creates a named entity within an org. -type Upserter interface { - Upsert(ctx context.Context, orgID, name string) (string, error) -} - -// Service handles CSV import staging and commit. -type Service struct { - repo *Repository - db *sql.DB - equipment *equipment.Repository - categories Upserter - manufacturers Upserter - locations Upserter - steps []Step -} - -// NewService returns a new Service with the default validation pipeline. -func NewService(repo *Repository, db *sql.DB, equip *equipment.Repository, cats, mfrs, locs Upserter) *Service { - return &Service{ - repo: repo, - db: db, - equipment: equip, - categories: cats, - manufacturers: mfrs, - locations: locs, - steps: []Step{validateStep}, - } -} - -// validateStep marks rows that fail field-level validation as StateInvalid. -func validateStep(_ context.Context, rows []ProcessedRow) ([]ProcessedRow, error) { - for i, pr := range rows { - if pr.State == StateInvalid { - continue - } - if msg := validateRow(pr.Data); msg != "" { - rows[i].State = StateInvalid - rows[i].Errors = append(rows[i].Errors, ValidationError{ - Line: int(pr.Data.RowNumber), - Field: "Name", - Reason: msg, - }) - } - } - return rows, nil -} - -// Stage deletes any existing staging rows for the org, runs the import pipeline, -// and persists the results. Returns the import_id grouping the new rows. -func (s *Service) Stage(ctx context.Context, orgID string, processed []ProcessedRow) (string, error) { - if err := s.repo.DeleteByOrgID(ctx, orgID); err != nil { - return "", fmt.Errorf("Stage: %w", err) - } - - existing, _, err := s.equipment.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32}) - if err != nil { - return "", fmt.Errorf("Stage: %w", err) - } - existingByName := make(map[string]string, len(existing)) - for _, item := range existing { - existingByName[strings.ToLower(item.Name)] = item.ID - } - - importID := uid.New() - for i := range processed { - processed[i].Data.ID = uid.New() - processed[i].Data.ImportID = importID - processed[i].Data.OrgID = orgID - processed[i].Data.RowNumber = int64(i + 1) - processed[i].Data.Status = StatusNew - processed[i].Data.Action = ActionCreate - } - - pipeline := make([]Step, len(s.steps)+1) - copy(pipeline, s.steps) - pipeline[len(s.steps)] = conflictStep(existingByName) - processed, err = RunValidation(ctx, processed, pipeline) - if err != nil { - return "", fmt.Errorf("Stage: pipeline: %w", err) - } - - rows := toRows(processed) - for i, row := range rows { - if _, err := s.repo.Create(ctx, row); err != nil { - return "", fmt.Errorf("Stage: row %d: %w", i+1, err) - } - } - - return importID, nil -} - -// toRows converts a validated []ProcessedRow back to []Row for storage, -// mapping StateInvalid→StatusError and collecting the first error message. -func toRows(processed []ProcessedRow) []Row { - rows := make([]Row, len(processed)) - for i, pr := range processed { - row := pr.Data - if pr.State == StateInvalid { - row.Status = StatusError - row.Action = ActionSkip - if len(pr.Errors) > 0 { - row.ErrorMessage = pr.Errors[0].Reason - } - } - rows[i] = row - } - return rows -} - -// conflictStep returns a Step that marks rows whose name already exists in inventory. -func conflictStep(existingByName map[string]string) Step { - return func(_ context.Context, rows []ProcessedRow) ([]ProcessedRow, error) { - for i, pr := range rows { - if pr.State == StateInvalid { - continue - } - if _, conflict := existingByName[strings.ToLower(pr.Data.Name)]; conflict { - rows[i].State = StateInvalid - rows[i].Errors = append(rows[i].Errors, ValidationError{ - Line: int(pr.Data.RowNumber), - Field: "Name", - Reason: "A gear item with this name already exists", - }) - } - } - return rows, nil - } -} - -// ListStaged returns all staged rows for an import. -func (s *Service) ListStaged(ctx context.Context, importID string) ([]Row, error) { - rows, err := s.repo.List(ctx, importID) - if err != nil { - return nil, fmt.Errorf("ListStaged: %w", err) - } - return rows, nil -} - -// commitLookups holds pre-resolved name→ID maps for the commit phase. -type commitLookups struct { - catsByName map[string]string - mfrsByName map[string]string - locsByName map[string]string -} - -// ensureCommitLookups upserts every unique category, manufacturer, -// and location name in the batch, creating them if they don't exist yet. -func (s *Service) ensureCommitLookups(ctx context.Context, orgID string, rows []Row) (commitLookups, error) { - lk := commitLookups{ - catsByName: make(map[string]string), - mfrsByName: make(map[string]string), - locsByName: make(map[string]string), - } - for _, row := range rows { - if row.Status == StatusError || row.Action == ActionSkip { - continue - } - if err := s.ensureCategory(ctx, &lk, orgID, row.CategoryName); err != nil { - return commitLookups{}, fmt.Errorf("ensureCommitLookups: %w", err) - } - if name := strings.TrimSpace(row.ManufacturerName); name != "" { - if err := s.ensureManufacturer(ctx, &lk, orgID, name); err != nil { - return commitLookups{}, fmt.Errorf("ensureCommitLookups: %w", err) - } - } - if name := strings.TrimSpace(row.LocationName); name != "" { - if err := s.ensureLocation(ctx, &lk, orgID, name); err != nil { - return commitLookups{}, fmt.Errorf("ensureCommitLookups: %w", err) - } - } - } - return lk, nil -} - -func (s *Service) ensureCategory(ctx context.Context, lk *commitLookups, orgID, name string) error { - key := strings.ToLower(name) - if _, ok := lk.catsByName[key]; ok { - return nil - } - id, err := s.categories.Upsert(ctx, orgID, name) - if err != nil { - return fmt.Errorf("category %q: %w", name, err) - } - lk.catsByName[key] = id - return nil -} - -func (s *Service) ensureManufacturer(ctx context.Context, lk *commitLookups, orgID, name string) error { - key := strings.ToLower(name) - if _, ok := lk.mfrsByName[key]; ok { - return nil - } - id, err := s.manufacturers.Upsert(ctx, orgID, name) - if err != nil { - return fmt.Errorf("manufacturer %q: %w", name, err) - } - lk.mfrsByName[key] = id - return nil -} - -func (s *Service) ensureLocation(ctx context.Context, lk *commitLookups, orgID, name string) error { - key := strings.ToLower(name) - if _, ok := lk.locsByName[key]; ok { - return nil - } - id, err := s.locations.Upsert(ctx, orgID, name) - if err != nil { - return fmt.Errorf("location %q: %w", name, err) - } - lk.locsByName[key] = id - return nil -} - -// Commit processes all staged rows for the import atomically: creates new items, -// then deletes the staging rows — all within a single transaction so no partial -// write is possible. -func (s *Service) Commit(ctx context.Context, importID string, orgID string) error { - rows, err := s.repo.List(ctx, importID) - if err != nil { - return fmt.Errorf("Commit: %w", err) - } - - lk, err := s.ensureCommitLookups(ctx, orgID, rows) - if err != nil { - return fmt.Errorf("Commit: %w", err) - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("Commit: begin tx: %w", err) - } - defer func() { _ = tx.Rollback() }() - - if err := s.commitRows(ctx, tx, rows, lk); err != nil { - return fmt.Errorf("Commit: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("Commit: %w", err) - } - - // Staging rows are cleaned up after the inventory transaction commits. - // A failure here leaves stale rows that DeleteByOrgID will clear on the next import. - if err := s.repo.Delete(ctx, importID); err != nil { - return fmt.Errorf("Commit: %w", err) - } - return nil -} - -type serializedGroup struct { - first Row - rows []Row -} - -// commitRows groups serialized rows by name and commits all bulk and serialized items. -func (s *Service) commitRows(ctx context.Context, tx *sql.Tx, rows []Row, lk commitLookups) error { - byName := make(map[string]*serializedGroup) - var order []string - for _, row := range rows { - if row.Status == StatusError || row.Action == ActionSkip { - continue - } - if !strings.EqualFold(row.TypeLabel, "serialized") { - if err := s.commitBulkRow(ctx, tx, row, lk); err != nil { - return fmt.Errorf("row %d: %w", row.RowNumber, err) - } - continue - } - key := strings.ToLower(row.Name) - if _, ok := byName[key]; !ok { - byName[key] = &serializedGroup{first: row} - order = append(order, key) - } - byName[key].rows = append(byName[key].rows, row) - } - for _, key := range order { - g := byName[key] - if err := s.commitSerializedGroup(ctx, tx, g.first, g.rows, lk); err != nil { - return fmt.Errorf("serialized %q: %w", g.first.Name, err) - } - } - return nil -} - -func (s *Service) resolveLookups(row Row, lk commitLookups) (catID, mfrID, locID string) { - catID = lk.catsByName[strings.ToLower(row.CategoryName)] - mfrID = lk.mfrsByName[strings.ToLower(row.ManufacturerName)] - locID = lk.locsByName[strings.ToLower(row.LocationName)] - return -} - -// parseInt64 parses a string as int64, returning 0 for blank or invalid input. -func parseInt64(s string) int64 { - n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64) - return n -} - -// ptrOf converts an int64 DB value to a typed pointer; returns nil when v is 0. -func ptrOf[T ~int64](v int64) *T { - if v == 0 { - return nil - } - t := T(v) - return &t -} - -func buildBase(row Row, catID, mfrID, locID string) equipment.Base { - return equipment.Base{ - OrgID: row.OrgID, - UsageTypeID: usage.Rental.ID(), - Name: row.Name, - CategoryID: catID, - ManufacturerID: mfrID, - LocationID: locID, - Notes: row.Notes, - EquipmentType: equipment.ParseOrDefault(strings.ToLower(strings.TrimSpace(row.EquipmentTypeLabel))), - Pricing: equipment.Pricing{ - PurchasePrice: ptrOf[units.Cents](parseInt64(row.ResalePrice)), - RentalPrice: ptrOf[units.Cents](parseInt64(row.RentalPrice)), - }, - Properties: equipment.Properties{ - Weight: ptrOf[units.Grams](parseInt64(row.WeightG)), - Width: ptrOf[units.Millimeters](parseInt64(row.WidthMm)), - Height: ptrOf[units.Millimeters](parseInt64(row.HeightMm)), - Depth: ptrOf[units.Millimeters](parseInt64(row.DepthMm)), - Power: ptrOf[units.Milliwatts](parseInt64(row.PowerMw)), - Current: ptrOf[units.Milliamps](parseInt64(row.CurrentMa)), - Voltage: ptrOf[units.Millivolts](parseInt64(row.VoltageMv)), - WireGauge: ptrOf[units.WireGauge](parseInt64(row.WireGaugeMM2X100)), - }, - } -} - -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.equipment.CreateBulk(ctx, tx, equipment.CreateBulkEquipment{ - ID: uid.New(), - BulkItemID: uid.New(), - Base: base, - TotalStock: equipment.ParseQuantity(row.Quantity), - }); err != nil { - return fmt.Errorf("commitBulkRow: %w", err) - } - return nil -} - -func buildUnit(row Row, equipmentID string) equipment.CreateUnit { - sn := strings.TrimSpace(row.UnitSerialNumber) - if sn == "" { - sn = serial.New() - } - isActive := !strings.EqualFold(row.UnitIsActive, "false") && row.UnitIsActive != "0" - return equipment.CreateUnit{ - ID: uid.New(), - EquipmentID: equipmentID, - SerialNumber: sn, - ManufacturerSerialNumber: row.UnitManufacturerSerial, - Remark: row.UnitRemark, - PurchasePrice: ptrOf[units.Cents](parseInt64(row.UnitPurchasePrice)), - PurchasedAt: equipment.ParseDate(row.UnitPurchasedAt), - NextInspectionAt: equipment.ParseDate(row.NextInspectionAt), - IsActive: isActive, - } -} - -// commitSerializedGroup creates one equipment item with one unit per row in the group. -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 := uid.New() - unitList := make([]equipment.CreateUnit, 0, len(rows)) - for _, row := range rows { - unitList = append(unitList, buildUnit(row, itemID)) - } - if _, err := s.equipment.CreateSerialized(ctx, tx, equipment.CreateSerializedEquipment{ - ID: itemID, - Base: base, - Units: unitList, - }); err != nil { - return fmt.Errorf("commitSerializedGroup: %w", err) - } - return nil -} - -func validateRow(row Row) string { - if strings.TrimSpace(row.Name) == "" { - return "Name is required" - } - tl := strings.TrimSpace(row.TypeLabel) - if !strings.EqualFold(tl, "bulk") && !strings.EqualFold(tl, "serialized") { - return fmt.Sprintf("Type must be Bulk or Serialized, got %q", tl) - } - ul := strings.TrimSpace(row.UsageTypeLabel) - if !strings.EqualFold(ul, "rental") && !strings.EqualFold(ul, "sale") { - return fmt.Sprintf("Usage must be Rental or Sale, got %q", ul) - } - if strings.TrimSpace(row.CategoryName) == "" { - return "Category is required" - } - return "" -} diff --git a/internal/equipmentimports/template.csv b/internal/equipmentimports/template.csv deleted file mode 100644 index 19864ec..0000000 --- a/internal/equipmentimports/template.csv +++ /dev/null @@ -1,5 +0,0 @@ -Name,Type,Usage,Category,Manufacturer,Location,Rental Price,Resale Price,Notes,Weight (kg),Width (cm),Height (cm),Depth (cm),Voltage (V),Current (A),Power (W),Wire Gauge (mm² ×100),Quantity,Equipment Type,Unit Serial Number,Unit Manufacturer Serial,Unit Purchase Price,Unit Purchased At,Next Inspection At,Unit Active,Unit Remark -Shure SM58,Bulk,Rental,Audio,Shure,Main Warehouse,15.00,99.00,Cardioid dynamic vocal microphone,0.298,4.7,4.7,16.2,,,,,7,,,,,,,, -Sony A7 IV,Serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,,SN-A7IV-001,7-000001,2800.00,2024-03-15,2025-03-15,TRUE, -Sony A7 IV,Serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,,SN-A7IV-002,7-000002,2800.00,2024-03-15,2025-03-15,TRUE,Minor scratch on top plate -Pelican 1510 Case,Serialized,Rental,Case,Pelican,Main Warehouse,10.00,120.00,Carry-on approved hard case,2.8,56.3,35.5,22.9,,,,,,Kit,PC-1510-001,,120.00,2024-01-10,,TRUE, diff --git a/sqlc.sqlite.yml b/sqlc.sqlite.yml index 5d5b86f..e2279a8 100644 --- a/sqlc.sqlite.yml +++ b/sqlc.sqlite.yml @@ -165,16 +165,6 @@ sql: emit_interface: true omit_unused_structs: true - - engine: "sqlite" - queries: "internal/database/queries/sqlite/equipment_imports.sql" - schema: "internal/database/migrations/sqlite/" - gen: - go: - package: "equipmentimports" - out: "internal/database/queries/gen/equipmentimports" - emit_interface: true - omit_unused_structs: true - - engine: "sqlite" queries: "internal/database/queries/sqlite/warehouse_locations.sql" schema: "internal/database/migrations/sqlite/" From 519da2963f020f602df12647d567042abf473d58 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:28:38 -0400 Subject: [PATCH 04/16] chore: added sql queries for import sessions, data, and mappings --- .../database/queries/gen/importdata/db.go | 31 +++ .../queries/gen/importdata/import_data.sql.go | 207 ++++++++++++++++++ .../database/queries/gen/importdata/models.go | 15 ++ .../queries/gen/importdata/querier.go | 21 ++ .../database/queries/gen/importmappings/db.go | 31 +++ .../gen/importmappings/import_mappings.sql.go | 84 +++++++ .../queries/gen/importmappings/models.go | 12 + .../queries/gen/importmappings/querier.go | 17 ++ .../database/queries/gen/importsessions/db.go | 31 +++ .../gen/importsessions/import_sessions.sql.go | 99 +++++++++ .../queries/gen/importsessions/models.go | 14 ++ .../queries/gen/importsessions/querier.go | 18 ++ .../database/queries/sqlite/import_data.sql | 34 +++ .../queries/sqlite/import_mappings.sql | 13 ++ .../queries/sqlite/import_sessions.sql | 18 ++ sqlc.sqlite.yml | 30 +++ 16 files changed, 675 insertions(+) create mode 100644 internal/database/queries/gen/importdata/db.go create mode 100644 internal/database/queries/gen/importdata/import_data.sql.go create mode 100644 internal/database/queries/gen/importdata/models.go create mode 100644 internal/database/queries/gen/importdata/querier.go create mode 100644 internal/database/queries/gen/importmappings/db.go create mode 100644 internal/database/queries/gen/importmappings/import_mappings.sql.go create mode 100644 internal/database/queries/gen/importmappings/models.go create mode 100644 internal/database/queries/gen/importmappings/querier.go create mode 100644 internal/database/queries/gen/importsessions/db.go create mode 100644 internal/database/queries/gen/importsessions/import_sessions.sql.go create mode 100644 internal/database/queries/gen/importsessions/models.go create mode 100644 internal/database/queries/gen/importsessions/querier.go create mode 100644 internal/database/queries/sqlite/import_data.sql create mode 100644 internal/database/queries/sqlite/import_mappings.sql create mode 100644 internal/database/queries/sqlite/import_sessions.sql diff --git a/internal/database/queries/gen/importdata/db.go b/internal/database/queries/gen/importdata/db.go new file mode 100644 index 0000000..119dea5 --- /dev/null +++ b/internal/database/queries/gen/importdata/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importdata + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/queries/gen/importdata/import_data.sql.go b/internal/database/queries/gen/importdata/import_data.sql.go new file mode 100644 index 0000000..681a514 --- /dev/null +++ b/internal/database/queries/gen/importdata/import_data.sql.go @@ -0,0 +1,207 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: import_data.sql + +package importdata + +import ( + "context" +) + +const deleteDataBySession = `-- name: DeleteDataBySession :exec +DELETE FROM import_data +WHERE session_id = ? +` + +func (q *Queries) DeleteDataBySession(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deleteDataBySession, sessionID) + return err +} + +const getData = `-- name: GetData :one +SELECT id, session_id, row_number, data, status, error_message, "action" FROM import_data +WHERE id = ? +` + +func (q *Queries) GetData(ctx context.Context, id string) (ImportDatum, error) { + row := q.db.QueryRowContext(ctx, getData, id) + var i ImportDatum + err := row.Scan( + &i.ID, + &i.SessionID, + &i.RowNumber, + &i.Data, + &i.Status, + &i.ErrorMessage, + &i.Action, + ) + return i, err +} + +const insertData = `-- name: InsertData :one +INSERT INTO import_data (id, session_id, row_number, data) +VALUES (?, ?, ?, ?) +RETURNING id, session_id, row_number, data, status, error_message, "action" +` + +type InsertDataParams struct { + ID string + SessionID string + RowNumber int64 + Data string +} + +func (q *Queries) InsertData(ctx context.Context, arg InsertDataParams) (ImportDatum, error) { + row := q.db.QueryRowContext(ctx, insertData, + arg.ID, + arg.SessionID, + arg.RowNumber, + arg.Data, + ) + var i ImportDatum + err := row.Scan( + &i.ID, + &i.SessionID, + &i.RowNumber, + &i.Data, + &i.Status, + &i.ErrorMessage, + &i.Action, + ) + return i, err +} + +const listData = `-- name: ListData :many +SELECT id, session_id, row_number, data, status, error_message, "action" FROM import_data +WHERE session_id = ? +ORDER BY row_number ASC +` + +func (q *Queries) ListData(ctx context.Context, sessionID string) ([]ImportDatum, error) { + rows, err := q.db.QueryContext(ctx, listData, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ImportDatum + for rows.Next() { + var i ImportDatum + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.RowNumber, + &i.Data, + &i.Status, + &i.ErrorMessage, + &i.Action, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDataByAction = `-- name: ListDataByAction :many +SELECT id, session_id, row_number, data, status, error_message, "action" FROM import_data +WHERE session_id = ? AND action = ? +ORDER BY row_number ASC +` + +type ListDataByActionParams struct { + SessionID string + Action string +} + +func (q *Queries) ListDataByAction(ctx context.Context, arg ListDataByActionParams) ([]ImportDatum, error) { + rows, err := q.db.QueryContext(ctx, listDataByAction, arg.SessionID, arg.Action) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ImportDatum + for rows.Next() { + var i ImportDatum + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.RowNumber, + &i.Data, + &i.Status, + &i.ErrorMessage, + &i.Action, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateDataAction = `-- name: UpdateDataAction :one +UPDATE import_data +SET action = ? +WHERE id = ? +RETURNING id, session_id, row_number, data, status, error_message, "action" +` + +type UpdateDataActionParams struct { + Action string + ID string +} + +func (q *Queries) UpdateDataAction(ctx context.Context, arg UpdateDataActionParams) (ImportDatum, error) { + row := q.db.QueryRowContext(ctx, updateDataAction, arg.Action, arg.ID) + var i ImportDatum + err := row.Scan( + &i.ID, + &i.SessionID, + &i.RowNumber, + &i.Data, + &i.Status, + &i.ErrorMessage, + &i.Action, + ) + return i, err +} + +const updateDataStatus = `-- name: UpdateDataStatus :one +UPDATE import_data +SET status = ?, error_message = ? +WHERE id = ? +RETURNING id, session_id, row_number, data, status, error_message, "action" +` + +type UpdateDataStatusParams struct { + Status string + ErrorMessage string + ID string +} + +func (q *Queries) UpdateDataStatus(ctx context.Context, arg UpdateDataStatusParams) (ImportDatum, error) { + row := q.db.QueryRowContext(ctx, updateDataStatus, arg.Status, arg.ErrorMessage, arg.ID) + var i ImportDatum + err := row.Scan( + &i.ID, + &i.SessionID, + &i.RowNumber, + &i.Data, + &i.Status, + &i.ErrorMessage, + &i.Action, + ) + return i, err +} diff --git a/internal/database/queries/gen/importdata/models.go b/internal/database/queries/gen/importdata/models.go new file mode 100644 index 0000000..2165b48 --- /dev/null +++ b/internal/database/queries/gen/importdata/models.go @@ -0,0 +1,15 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importdata + +type ImportDatum struct { + ID string + SessionID string + RowNumber int64 + Data string + Status string + ErrorMessage string + Action string +} diff --git a/internal/database/queries/gen/importdata/querier.go b/internal/database/queries/gen/importdata/querier.go new file mode 100644 index 0000000..6f38817 --- /dev/null +++ b/internal/database/queries/gen/importdata/querier.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importdata + +import ( + "context" +) + +type Querier interface { + DeleteDataBySession(ctx context.Context, sessionID string) error + GetData(ctx context.Context, id string) (ImportDatum, error) + InsertData(ctx context.Context, arg InsertDataParams) (ImportDatum, error) + ListData(ctx context.Context, sessionID string) ([]ImportDatum, error) + ListDataByAction(ctx context.Context, arg ListDataByActionParams) ([]ImportDatum, error) + UpdateDataAction(ctx context.Context, arg UpdateDataActionParams) (ImportDatum, error) + UpdateDataStatus(ctx context.Context, arg UpdateDataStatusParams) (ImportDatum, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/gen/importmappings/db.go b/internal/database/queries/gen/importmappings/db.go new file mode 100644 index 0000000..e93767f --- /dev/null +++ b/internal/database/queries/gen/importmappings/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importmappings + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/queries/gen/importmappings/import_mappings.sql.go b/internal/database/queries/gen/importmappings/import_mappings.sql.go new file mode 100644 index 0000000..82b1230 --- /dev/null +++ b/internal/database/queries/gen/importmappings/import_mappings.sql.go @@ -0,0 +1,84 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: import_mappings.sql + +package importmappings + +import ( + "context" +) + +const deleteMappingsBySession = `-- name: DeleteMappingsBySession :exec +DELETE FROM import_mappings +WHERE session_id = ? +` + +func (q *Queries) DeleteMappingsBySession(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deleteMappingsBySession, sessionID) + return err +} + +const insertMapping = `-- name: InsertMapping :one +INSERT INTO import_mappings (id, session_id, source_col, target_field) +VALUES (?, ?, ?, ?) +RETURNING id, session_id, source_col, target_field +` + +type InsertMappingParams struct { + ID string + SessionID string + SourceCol string + TargetField string +} + +func (q *Queries) InsertMapping(ctx context.Context, arg InsertMappingParams) (ImportMapping, error) { + row := q.db.QueryRowContext(ctx, insertMapping, + arg.ID, + arg.SessionID, + arg.SourceCol, + arg.TargetField, + ) + var i ImportMapping + err := row.Scan( + &i.ID, + &i.SessionID, + &i.SourceCol, + &i.TargetField, + ) + return i, err +} + +const listMappings = `-- name: ListMappings :many +SELECT id, session_id, source_col, target_field FROM import_mappings +WHERE session_id = ? +ORDER BY source_col ASC +` + +func (q *Queries) ListMappings(ctx context.Context, sessionID string) ([]ImportMapping, error) { + rows, err := q.db.QueryContext(ctx, listMappings, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ImportMapping + for rows.Next() { + var i ImportMapping + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SourceCol, + &i.TargetField, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/database/queries/gen/importmappings/models.go b/internal/database/queries/gen/importmappings/models.go new file mode 100644 index 0000000..343ba86 --- /dev/null +++ b/internal/database/queries/gen/importmappings/models.go @@ -0,0 +1,12 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importmappings + +type ImportMapping struct { + ID string + SessionID string + SourceCol string + TargetField string +} diff --git a/internal/database/queries/gen/importmappings/querier.go b/internal/database/queries/gen/importmappings/querier.go new file mode 100644 index 0000000..cc7a741 --- /dev/null +++ b/internal/database/queries/gen/importmappings/querier.go @@ -0,0 +1,17 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importmappings + +import ( + "context" +) + +type Querier interface { + DeleteMappingsBySession(ctx context.Context, sessionID string) error + InsertMapping(ctx context.Context, arg InsertMappingParams) (ImportMapping, error) + ListMappings(ctx context.Context, sessionID string) ([]ImportMapping, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/gen/importsessions/db.go b/internal/database/queries/gen/importsessions/db.go new file mode 100644 index 0000000..1ad75c1 --- /dev/null +++ b/internal/database/queries/gen/importsessions/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importsessions + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/queries/gen/importsessions/import_sessions.sql.go b/internal/database/queries/gen/importsessions/import_sessions.sql.go new file mode 100644 index 0000000..69e9372 --- /dev/null +++ b/internal/database/queries/gen/importsessions/import_sessions.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: import_sessions.sql + +package importsessions + +import ( + "context" +) + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM import_sessions +WHERE id = ? +` + +func (q *Queries) DeleteSession(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteSession, id) + return err +} + +const getSession = `-- name: GetSession :one +SELECT id, org_id, format, status, target_entity, created_at FROM import_sessions +WHERE id = ? +` + +func (q *Queries) GetSession(ctx context.Context, id string) (ImportSession, error) { + row := q.db.QueryRowContext(ctx, getSession, id) + var i ImportSession + err := row.Scan( + &i.ID, + &i.OrgID, + &i.Format, + &i.Status, + &i.TargetEntity, + &i.CreatedAt, + ) + return i, err +} + +const insertSession = `-- name: InsertSession :one +INSERT INTO import_sessions (id, org_id, format, status, target_entity) +VALUES (?, ?, ?, ?, ?) +RETURNING id, org_id, format, status, target_entity, created_at +` + +type InsertSessionParams struct { + ID string + OrgID string + Format string + Status string + TargetEntity string +} + +func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (ImportSession, error) { + row := q.db.QueryRowContext(ctx, insertSession, + arg.ID, + arg.OrgID, + arg.Format, + arg.Status, + arg.TargetEntity, + ) + var i ImportSession + err := row.Scan( + &i.ID, + &i.OrgID, + &i.Format, + &i.Status, + &i.TargetEntity, + &i.CreatedAt, + ) + return i, err +} + +const updateSessionStatus = `-- name: UpdateSessionStatus :one +UPDATE import_sessions +SET status = ? +WHERE id = ? +RETURNING id, org_id, format, status, target_entity, created_at +` + +type UpdateSessionStatusParams struct { + Status string + ID string +} + +func (q *Queries) UpdateSessionStatus(ctx context.Context, arg UpdateSessionStatusParams) (ImportSession, error) { + row := q.db.QueryRowContext(ctx, updateSessionStatus, arg.Status, arg.ID) + var i ImportSession + err := row.Scan( + &i.ID, + &i.OrgID, + &i.Format, + &i.Status, + &i.TargetEntity, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/database/queries/gen/importsessions/models.go b/internal/database/queries/gen/importsessions/models.go new file mode 100644 index 0000000..bc7a5ea --- /dev/null +++ b/internal/database/queries/gen/importsessions/models.go @@ -0,0 +1,14 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importsessions + +type ImportSession struct { + ID string + OrgID string + Format string + Status string + TargetEntity string + CreatedAt int64 +} diff --git a/internal/database/queries/gen/importsessions/querier.go b/internal/database/queries/gen/importsessions/querier.go new file mode 100644 index 0000000..0c5cfd0 --- /dev/null +++ b/internal/database/queries/gen/importsessions/querier.go @@ -0,0 +1,18 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importsessions + +import ( + "context" +) + +type Querier interface { + DeleteSession(ctx context.Context, id string) error + GetSession(ctx context.Context, id string) (ImportSession, error) + InsertSession(ctx context.Context, arg InsertSessionParams) (ImportSession, error) + UpdateSessionStatus(ctx context.Context, arg UpdateSessionStatusParams) (ImportSession, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/sqlite/import_data.sql b/internal/database/queries/sqlite/import_data.sql new file mode 100644 index 0000000..6733946 --- /dev/null +++ b/internal/database/queries/sqlite/import_data.sql @@ -0,0 +1,34 @@ +-- name: InsertData :one +INSERT INTO import_data (id, session_id, row_number, data) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: GetData :one +SELECT * FROM import_data +WHERE id = ?; + +-- name: ListData :many +SELECT * FROM import_data +WHERE session_id = ? +ORDER BY row_number ASC; + +-- name: ListDataByAction :many +SELECT * FROM import_data +WHERE session_id = ? AND action = ? +ORDER BY row_number ASC; + +-- name: UpdateDataAction :one +UPDATE import_data +SET action = ? +WHERE id = ? +RETURNING *; + +-- name: UpdateDataStatus :one +UPDATE import_data +SET status = ?, error_message = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteDataBySession :exec +DELETE FROM import_data +WHERE session_id = ?; diff --git a/internal/database/queries/sqlite/import_mappings.sql b/internal/database/queries/sqlite/import_mappings.sql new file mode 100644 index 0000000..6f53cdc --- /dev/null +++ b/internal/database/queries/sqlite/import_mappings.sql @@ -0,0 +1,13 @@ +-- name: InsertMapping :one +INSERT INTO import_mappings (id, session_id, source_col, target_field) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: ListMappings :many +SELECT * FROM import_mappings +WHERE session_id = ? +ORDER BY source_col ASC; + +-- name: DeleteMappingsBySession :exec +DELETE FROM import_mappings +WHERE session_id = ?; diff --git a/internal/database/queries/sqlite/import_sessions.sql b/internal/database/queries/sqlite/import_sessions.sql new file mode 100644 index 0000000..295562f --- /dev/null +++ b/internal/database/queries/sqlite/import_sessions.sql @@ -0,0 +1,18 @@ +-- name: InsertSession :one +INSERT INTO import_sessions (id, org_id, format, status, target_entity) +VALUES (?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetSession :one +SELECT * FROM import_sessions +WHERE id = ?; + +-- name: UpdateSessionStatus :one +UPDATE import_sessions +SET status = ? +WHERE id = ? +RETURNING *; + +-- name: DeleteSession :exec +DELETE FROM import_sessions +WHERE id = ?; diff --git a/sqlc.sqlite.yml b/sqlc.sqlite.yml index e2279a8..250707e 100644 --- a/sqlc.sqlite.yml +++ b/sqlc.sqlite.yml @@ -214,3 +214,33 @@ sql: out: "internal/database/queries/gen/federated" emit_interface: true omit_unused_structs: true + + - engine: "sqlite" + queries: "internal/database/queries/sqlite/import_sessions.sql" + schema: "internal/database/migrations/sqlite/" + gen: + go: + package: "importsessions" + out: "internal/database/queries/gen/importsessions" + emit_interface: true + omit_unused_structs: true + + - engine: "sqlite" + queries: "internal/database/queries/sqlite/import_data.sql" + schema: "internal/database/migrations/sqlite/" + gen: + go: + package: "importdata" + out: "internal/database/queries/gen/importdata" + emit_interface: true + omit_unused_structs: true + + - engine: "sqlite" + queries: "internal/database/queries/sqlite/import_mappings.sql" + schema: "internal/database/migrations/sqlite/" + gen: + go: + package: "importmappings" + out: "internal/database/queries/gen/importmappings" + emit_interface: true + omit_unused_structs: true From 91d8901cacae53cb90f760d47276fb9a7827afa7 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:41:26 -0400 Subject: [PATCH 05/16] chore: added model, repo, and service to prepare the import pkg --- .../sqlite/00025_import_sessions.sql | 2 +- internal/import/model.go | 99 +++++++++++ internal/import/repository.go | 164 +++++++++++++++++ internal/import/service.go | 165 ++++++++++++++++++ wiki/IMPORTS_ERD.md | 2 +- 5 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 internal/import/model.go create mode 100644 internal/import/repository.go create mode 100644 internal/import/service.go diff --git a/internal/database/migrations/sqlite/00025_import_sessions.sql b/internal/database/migrations/sqlite/00025_import_sessions.sql index 3b86a3d..f2aa485 100644 --- a/internal/database/migrations/sqlite/00025_import_sessions.sql +++ b/internal/database/migrations/sqlite/00025_import_sessions.sql @@ -4,7 +4,7 @@ CREATE TABLE import_sessions ( id TEXT NOT NULL PRIMARY KEY, org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, format TEXT NOT NULL, -- csv | json | excel - status TEXT NOT NULL, -- uploading | mapping | staged | committed + status TEXT NOT NULL, -- pending | mapped | staged | committed target_entity TEXT NOT NULL, -- equipment | ... created_at INTEGER NOT NULL DEFAULT (unixepoch()) ) STRICT; diff --git a/internal/import/model.go b/internal/import/model.go new file mode 100644 index 0000000..e70c526 --- /dev/null +++ b/internal/import/model.go @@ -0,0 +1,99 @@ +// 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 imports provides format-agnostic import functionality. +package imports + +import "errors" + +var ErrNotFound = errors.New("import: not found") + +// Format identifies the source file format. +type Format string + +const ( + FormatCSV Format = "csv" +) + +// Status tracks the lifecycle of an import session. +// Each value describes the state the session is in, not the action that caused it. +type Status string + +const ( + StatusPending Status = "pending" // rows stored, awaiting column mapping + StatusMapped Status = "mapped" // column mappings saved, awaiting validation + StatusStaged Status = "staged" // validation ran, rows ready for review + StatusCommitted Status = "committed" // import written to inventory +) + +// Action is the user's decision for a single row. +type Action string + +const ( + ActionPending Action = "pending" + ActionCreate Action = "create" + ActionSkip Action = "skip" +) + +// RowStatus is the system's assessment of a single row. +type RowStatus string + +const ( + RowStatusNew RowStatus = "new" + RowStatusError RowStatus = "error" + RowStatusNeedsReview RowStatus = "needs_review" +) + +// Session represents an import session. +type Session struct { + ID string + OrgID string + Format Format + Status Status + TargetEntity string + CreatedAt int64 +} + +// Row is a single staged import row. +type Row struct { + ID string + SessionID string + RowNumber int64 + Data string // JSON blob of raw source columns + Status RowStatus + ErrorMessage string + Action Action +} + +// Mapping is a user-defined column mapping for a session. +type Mapping struct { + ID string + SessionID string + SourceCol string + TargetField string +} + +// Decision is a user's action choice for a single row during review. +type Decision struct { + RowID string + Action Action +} + +// RawRecord is a parsed row from any source format. +// Fields maps source column names to their raw string values. +type RawRecord struct { + RowNumber int + Fields map[string]string +} diff --git a/internal/import/repository.go b/internal/import/repository.go new file mode 100644 index 0000000..27ab167 --- /dev/null +++ b/internal/import/repository.go @@ -0,0 +1,164 @@ +// 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 imports + +import ( + "context" + "database/sql" + "errors" + "fmt" + + gendata "github.com/bit8bytes/gearberg/internal/database/queries/gen/importdata" + genmappings "github.com/bit8bytes/gearberg/internal/database/queries/gen/importmappings" + gensessions "github.com/bit8bytes/gearberg/internal/database/queries/gen/importsessions" +) + +// Repository provides data access for import sessions, rows, and mappings. +type Repository struct { + db *sql.DB + sessions *gensessions.Queries + data *gendata.Queries + mappings *genmappings.Queries +} + +// NewRepository returns a new Repository. +func NewRepository(db *sql.DB) *Repository { + return &Repository{ + db: db, + sessions: gensessions.New(db), + data: gendata.New(db), + mappings: genmappings.New(db), + } +} + +func (r *Repository) InsertSessionTx(ctx context.Context, tx *sql.Tx, s Session) (Session, error) { + row, err := r.sessions.WithTx(tx).InsertSession(ctx, gensessions.InsertSessionParams{ + ID: s.ID, + OrgID: s.OrgID, + Format: string(s.Format), + Status: string(s.Status), + TargetEntity: s.TargetEntity, + }) + if err != nil { + return Session{}, fmt.Errorf("InsertSessionTx: %w", err) + } + return sessionFromRecord(row), nil +} + +func (r *Repository) GetSession(ctx context.Context, id string) (Session, error) { + row, err := r.sessions.GetSession(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrNotFound + } + return Session{}, fmt.Errorf("GetSession: %w", err) + } + return sessionFromRecord(row), nil +} + +func (r *Repository) UpdateSessionStatus(ctx context.Context, id string, status Status) (Session, error) { + row, err := r.sessions.UpdateSessionStatus(ctx, gensessions.UpdateSessionStatusParams{ + Status: string(status), + ID: id, + }) + if err != nil { + return Session{}, fmt.Errorf("UpdateSessionStatus: %w", err) + } + return sessionFromRecord(row), nil +} + +func (r *Repository) InsertDataTx(ctx context.Context, tx *sql.Tx, row Row) (Row, error) { + rec, err := r.data.WithTx(tx).InsertData(ctx, gendata.InsertDataParams{ + ID: row.ID, + SessionID: row.SessionID, + RowNumber: row.RowNumber, + Data: row.Data, + }) + if err != nil { + return Row{}, fmt.Errorf("InsertDataTx: %w", err) + } + return dataFromRecord(rec), nil +} + +func (r *Repository) ListDataByAction(ctx context.Context, sessionID string, action Action) ([]Row, error) { + recs, err := r.data.ListDataByAction(ctx, gendata.ListDataByActionParams{ + SessionID: sessionID, + Action: string(action), + }) + if err != nil { + return nil, fmt.Errorf("ListDataByAction: %w", err) + } + rows := make([]Row, len(recs)) + for i, rec := range recs { + rows[i] = dataFromRecord(rec) + } + return rows, nil +} + +func (r *Repository) UpdateDataAction(ctx context.Context, id string, action Action) (Row, error) { + rec, err := r.data.UpdateDataAction(ctx, gendata.UpdateDataActionParams{ + Action: string(action), + ID: id, + }) + if err != nil { + return Row{}, fmt.Errorf("UpdateDataAction: %w", err) + } + return dataFromRecord(rec), nil +} + +func (r *Repository) ListMappings(ctx context.Context, sessionID string) ([]Mapping, error) { + recs, err := r.mappings.ListMappings(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("ListMappings: %w", err) + } + ms := make([]Mapping, len(recs)) + for i, rec := range recs { + ms[i] = mappingFromRecord(rec) + } + return ms, nil +} + +func sessionFromRecord(r gensessions.ImportSession) Session { + return Session{ + ID: r.ID, + OrgID: r.OrgID, + Format: Format(r.Format), + Status: Status(r.Status), + TargetEntity: r.TargetEntity, + CreatedAt: r.CreatedAt, + } +} + +func dataFromRecord(r gendata.ImportDatum) Row { + return Row{ + ID: r.ID, + SessionID: r.SessionID, + RowNumber: r.RowNumber, + Data: r.Data, + Status: RowStatus(r.Status), + ErrorMessage: r.ErrorMessage, + Action: Action(r.Action), + } +} + +func mappingFromRecord(r genmappings.ImportMapping) Mapping { + return Mapping{ + ID: r.ID, + SessionID: r.SessionID, + SourceCol: r.SourceCol, + TargetField: r.TargetField, + } +} diff --git a/internal/import/service.go b/internal/import/service.go new file mode 100644 index 0000000..159ae1e --- /dev/null +++ b/internal/import/service.go @@ -0,0 +1,165 @@ +// 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 imports + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + + "github.com/bit8bytes/gearberg/internal/uid" +) + +// Reader parses an input source into raw records. +// Satisfied by *csv.Reader or any future format reader. +type Reader interface { + Read(ctx context.Context, r io.Reader) ([]RawRecord, error) +} + +// Inspector reads only the header layer of a file to discover column names +// for the field-mapping UI, without parsing the full dataset. +type Inspector interface { + InspectHeaders(ctx context.Context, r io.Reader) ([]string, error) +} + +// Step is a single validation stage applied to a batch of rows. +// A Step must never return early on a per-row error; it sets the row's +// status and error message and continues. +type Step func(ctx context.Context, repo *Repository, rows []Row) ([]Row, error) + +// Service orchestrates the import pipeline. +type Service struct { + db *sql.DB + repo *Repository + steps []Step +} + +// NewService returns a new Service with the given validation steps. +func NewService(db *sql.DB, repo *Repository, steps ...Step) *Service { + return &Service{db: db, repo: repo, steps: steps} +} + +// NewSession parses the file, creates an import session, stores raw rows, +// and runs validation. Returns the staged Session. +func (s *Service) NewSession(ctx context.Context, orgID string, format Format, targetEntity string, r io.Reader, reader Reader) (Session, error) { + records, err := reader.Read(ctx, r) + if err != nil { + return Session{}, fmt.Errorf("NewSession: read: %w", err) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Session{}, fmt.Errorf("NewSession: begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + session, err := s.repo.InsertSessionTx(ctx, tx, Session{ + ID: uid.New(), + OrgID: orgID, + Format: format, + Status: StatusPending, + TargetEntity: targetEntity, + }) + if err != nil { + return Session{}, fmt.Errorf("NewSession: %w", err) + } + + rows := make([]Row, 0, len(records)) + for _, rec := range records { + blob, err := json.Marshal(rec.Fields) + if err != nil { + return Session{}, fmt.Errorf("NewSession: marshal row %d: %w", rec.RowNumber, err) + } + row, err := s.repo.InsertDataTx(ctx, tx, Row{ + ID: uid.New(), + SessionID: session.ID, + RowNumber: int64(rec.RowNumber), + Data: string(blob), + }) + if err != nil { + return Session{}, fmt.Errorf("NewSession: insert row %d: %w", rec.RowNumber, err) + } + rows = append(rows, row) + } + + if err := tx.Commit(); err != nil { + return Session{}, fmt.Errorf("NewSession: commit: %w", err) + } + + rows, err = s.runSteps(ctx, rows) + if err != nil { + return Session{}, fmt.Errorf("NewSession: validate: %w", err) + } + + session, err = s.repo.UpdateSessionStatus(ctx, session.ID, StatusStaged) + if err != nil { + return Session{}, fmt.Errorf("NewSession: %w", err) + } + + return session, nil +} + +// Review applies user decisions to staged rows. +func (s *Service) Review(ctx context.Context, decisions []Decision) error { + for _, d := range decisions { + if _, err := s.repo.UpdateDataAction(ctx, d.RowID, d.Action); err != nil { + return fmt.Errorf("Review: row %s: %w", d.RowID, err) + } + } + return nil +} + +// Commit finalises the import. Target-entity writes are delegated to the +// caller via a CommitHandler so this package stays format- and domain-agnostic. +func (s *Service) Commit(ctx context.Context, sessionID string, handler CommitHandler) error { + rows, err := s.repo.ListDataByAction(ctx, sessionID, ActionCreate) + if err != nil { + return fmt.Errorf("Commit: %w", err) + } + + mappings, err := s.repo.ListMappings(ctx, sessionID) + if err != nil { + return fmt.Errorf("Commit: %w", err) + } + + if err := handler.Commit(ctx, rows, mappings); err != nil { + return fmt.Errorf("Commit: %w", err) + } + + if _, err := s.repo.UpdateSessionStatus(ctx, sessionID, StatusCommitted); err != nil { + return fmt.Errorf("Commit: %w", err) + } + + return nil +} + +// CommitHandler writes committed rows to the target domain (e.g. equipment). +type CommitHandler interface { + Commit(ctx context.Context, rows []Row, mappings []Mapping) error +} + +func (s *Service) runSteps(ctx context.Context, rows []Row) ([]Row, error) { + var err error + for _, step := range s.steps { + rows, err = step(ctx, s.repo, rows) + if err != nil { + return nil, err + } + } + return rows, nil +} diff --git a/wiki/IMPORTS_ERD.md b/wiki/IMPORTS_ERD.md index acf6f4e..4f11804 100644 --- a/wiki/IMPORTS_ERD.md +++ b/wiki/IMPORTS_ERD.md @@ -12,7 +12,7 @@ erDiagram text id PK text org_id FK text format "csv | json | excel" - text status "uploading | mapping | staged | committed" + text status "pending | mapped | staged | committed" text target_entity "equipment" integer created_at "NOT NULL DEFAULT unixepoch()" } From 03cf1c31714dfc61e3be6c145613875f85280172 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:43:23 -0400 Subject: [PATCH 06/16] chore: added CSV reader to internal/import pkg to satisfy Reader and Inspector --- internal/import/csv.go | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 internal/import/csv.go diff --git a/internal/import/csv.go b/internal/import/csv.go new file mode 100644 index 0000000..6998de9 --- /dev/null +++ b/internal/import/csv.go @@ -0,0 +1,57 @@ +// 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 imports + +import ( + "context" + "fmt" + "io" + + pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" +) + +// CSVReader adapts pkg/csv.Reader to the imports.Reader and imports.Inspector interfaces. +type CSVReader struct { + r pkgcsv.Reader +} + +// NewCSVReader returns a CSVReader wrapping a pkg/csv.Reader. +func NewCSVReader(r pkgcsv.Reader) *CSVReader { + return &CSVReader{r: r} +} + +func (c *CSVReader) Read(ctx context.Context, r io.Reader) ([]RawRecord, error) { + recs, err := c.r.Read(ctx, r) + if err != nil { + return nil, fmt.Errorf("CSVReader.Read: %w", err) + } + out := make([]RawRecord, len(recs)) + for i, rec := range recs { + out[i] = RawRecord{ + RowNumber: rec.Line, + Fields: rec.Fields, + } + } + return out, nil +} + +func (c *CSVReader) InspectHeaders(ctx context.Context, r io.Reader) ([]string, error) { + headers, err := c.r.InspectHeaders(ctx, r) + if err != nil { + return nil, fmt.Errorf("CSVReader.InspectHeaders: %w", err) + } + return headers, nil +} From cfdda212288480cbf528408d9ef63e44c92054ef Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:55:47 -0400 Subject: [PATCH 07/16] chore: glue imports into the cmd/web and add handler for first import get post --- cmd/web/handlers:import.go | 59 ++++++++++++++++++++++++++++++++++++++ cmd/web/routes.go | 4 +++ cmd/web/setup.go | 6 ++++ 3 files changed, 69 insertions(+) create mode 100644 cmd/web/handlers:import.go diff --git a/cmd/web/handlers:import.go b/cmd/web/handlers:import.go new file mode 100644 index 0000000..2bf506e --- /dev/null +++ b/cmd/web/handlers:import.go @@ -0,0 +1,59 @@ +// 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 main + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/bit8bytes/gearberg/internal/httperr" + imports "github.com/bit8bytes/gearberg/internal/import" + pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" +) + +const importMaxBytes = 32 << 20 // 32 MiB + +// getImport serves the upload form. +func (app *application) getImport(w http.ResponseWriter, r *http.Request) *httperr.Error { + // TODO: render upload page template + return nil +} + +// postImport parses the uploaded file, creates an import session, and redirects +// to the mapping step. +func (app *application) postImport(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + + if err := r.ParseMultipartForm(importMaxBytes); err != nil { + return httperr.BadRequest(fmt.Errorf("file too large or malformed: %w", err)) + } + + file, _, err := r.FormFile("file") + if err != nil { + return httperr.BadRequest(fmt.Errorf("missing file: %w", err)) + } + defer file.Close() + + reader := imports.NewCSVReader(pkgcsv.Reader{}) + session, err := app.services.imports.NewSession(ctx, orgID, imports.FormatCSV, "equipment", file, reader) + if err != nil { + return httperr.InternalServerError(err) + } + + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(session.ID)+"/map", http.StatusSeeOther) + return nil +} diff --git a/cmd/web/routes.go b/cmd/web/routes.go index d960949..78e8bc1 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -80,6 +80,10 @@ func (app *application) routes() (http.Handler, error) { mux.Handle("GET /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.getSettingsOrg)))) mux.Handle("POST /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.postSettingsOrg)))) + // Import + mux.Handle("GET /orgs/{org_id}/import", app.withLogin(app.withPermission(app.html.Handle(app.getImport)))) + mux.Handle("POST /orgs/{org_id}/import", app.withLogin(app.withPermission(app.html.Handle(app.postImport)))) + // Equipment mux.Handle("GET /orgs/{org_id}/equipment", app.withLogin(app.withPermission(app.html.Handle(app.getEquipment)))) mux.Handle("GET /orgs/{org_id}/equipment/print", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPrint)))) diff --git a/cmd/web/setup.go b/cmd/web/setup.go index 6037206..0397411 100644 --- a/cmd/web/setup.go +++ b/cmd/web/setup.go @@ -39,6 +39,7 @@ import ( "github.com/bit8bytes/gearberg/internal/database/migrations" "github.com/bit8bytes/gearberg/internal/equipment" "github.com/bit8bytes/gearberg/internal/federated" + imports "github.com/bit8bytes/gearberg/internal/import" "github.com/bit8bytes/gearberg/internal/locations" "github.com/bit8bytes/gearberg/internal/manufacturers" "github.com/bit8bytes/gearberg/internal/orgs" @@ -232,6 +233,7 @@ type services struct { manufacturers *manufacturers.Service locations *locations.Service equipment *equipment.Service + imports *imports.Service storageManager *storage.Manager } @@ -266,6 +268,9 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*s inventoryRepo := equipment.NewRepository(db) inventorySvc := equipment.NewService(inventoryRepo, db) + importsRepo := imports.NewRepository(db) + importsSvc := imports.NewService(db, importsRepo) + store, err := storage.Open("local", opts.StorageDSN, logger) if err != nil { return nil, fmt.Errorf("setupServices: open storage: %w", err) @@ -285,6 +290,7 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*s manufacturers: manufacturersSvc, locations: locationsSvc, equipment: inventorySvc, + imports: importsSvc, storageManager: storageMgr, }, nil } From 326b054cf3abf63fe8996a8d2afb9d2d7a44dbe0 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 08:57:41 -0400 Subject: [PATCH 08/16] chore: automap fields to not implement mapping UI --- internal/import/repository.go | 13 +++++++++++++ internal/import/service.go | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/internal/import/repository.go b/internal/import/repository.go index 27ab167..822627a 100644 --- a/internal/import/repository.go +++ b/internal/import/repository.go @@ -119,6 +119,19 @@ func (r *Repository) UpdateDataAction(ctx context.Context, id string, action Act return dataFromRecord(rec), nil } +func (r *Repository) InsertMappingTx(ctx context.Context, tx *sql.Tx, m Mapping) (Mapping, error) { + rec, err := r.mappings.WithTx(tx).InsertMapping(ctx, genmappings.InsertMappingParams{ + ID: m.ID, + SessionID: m.SessionID, + SourceCol: m.SourceCol, + TargetField: m.TargetField, + }) + if err != nil { + return Mapping{}, fmt.Errorf("InsertMappingTx: %w", err) + } + return mappingFromRecord(rec), nil +} + func (r *Repository) ListMappings(ctx context.Context, sessionID string) ([]Mapping, error) { recs, err := r.mappings.ListMappings(ctx, sessionID) if err != nil { diff --git a/internal/import/service.go b/internal/import/service.go index 159ae1e..9599f4b 100644 --- a/internal/import/service.go +++ b/internal/import/service.go @@ -79,6 +79,23 @@ func (s *Service) NewSession(ctx context.Context, orgID string, format Format, t return Session{}, fmt.Errorf("NewSession: %w", err) } + // Auto-map source col → target field using the first record's keys. + // The mapping UI is not implemented yet, so we assume the source columns + // already match the internal field names (e.g. via the Gearberg template CSV). + // When the mapping UI is added, this block is replaced by user-defined mappings. + if len(records) > 0 { + for col := range records[0].Fields { + if _, err := s.repo.InsertMappingTx(ctx, tx, Mapping{ + ID: uid.New(), + SessionID: session.ID, + SourceCol: col, + TargetField: col, + }); err != nil { + return Session{}, fmt.Errorf("NewSession: auto-map %q: %w", col, err) + } + } + } + rows := make([]Row, 0, len(records)) for _, rec := range records { blob, err := json.Marshal(rec.Fields) From fdad9d07cf52cdb8cbfbada94f33c68c1a97f27d Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 09:00:19 -0400 Subject: [PATCH 09/16] chore: added template.csv --- internal/import/template.csv | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 internal/import/template.csv diff --git a/internal/import/template.csv b/internal/import/template.csv new file mode 100644 index 0000000..a4c7cae --- /dev/null +++ b/internal/import/template.csv @@ -0,0 +1,5 @@ +Name,Type,Usage,Category,Manufacturer,Location,Rental Price,Resale Price,Notes,Weight (kg),Width (cm),Height (cm),Depth (cm),Voltage (V),Current (A),Power (W),Wire Gauge (mm² ×100),Quantity,Equipment Type,Unit Serial Number,Unit Manufacturer Serial,Unit Purchase Price,Unit Purchased At,Next Inspection At,Unit Active,Unit Remark +Shure SM58,Bulk,Rental,Audio,Shure,Main Warehouse,15.00,99.00,Cardioid dynamic vocal microphone,0.298,4.7,4.7,16.2,,,,,7,,,,,,,, +Sony A7 IV,Serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,,SN-A7IV-001,7-000001,2800.00,2024-03-15,2025-03-15,TRUE, +Sony A7 IV,Serialized,Rental,Camera,Sony,Main Warehouse,80.00,2800.00,Full-frame mirrorless camera,0.659,13.1,9.6,8.0,,,,,,,SN-A7IV-002,7-000002,2800.00,2024-03-15,2025-03-15,TRUE,Minor scratch on top plate +Pelican 1510 Case,Serialized,Rental,Case,Pelican,Main Warehouse,10.00,120.00,Carry-on approved hard case,2.8,56.3,35.5,22.9,,,,,,Kit,PC-1510-001,,120.00,2024-01-10,,TRUE, \ No newline at end of file From a8c07044a6f9a89b1b2b41a2b96c90ca467c0678 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sun, 23 Aug 2026 09:03:25 -0400 Subject: [PATCH 10/16] style: upload page and serve template.csv via handler --- cmd/web/handlers:import.go | 21 ++++++++- cmd/web/routes.go | 1 + internal/import/csv.go | 4 ++ internal/templates/pages/equipment/index.tmpl | 4 +- internal/templates/pages/import/upload.tmpl | 45 +++++++++++++++++++ internal/templates/pages/pages.go | 3 ++ 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 internal/templates/pages/import/upload.tmpl diff --git a/cmd/web/handlers:import.go b/cmd/web/handlers:import.go index 2bf506e..fd00735 100644 --- a/cmd/web/handlers:import.go +++ b/cmd/web/handlers:import.go @@ -21,15 +21,32 @@ import ( "github.com/bit8bytes/gearberg/internal/httperr" imports "github.com/bit8bytes/gearberg/internal/import" + "github.com/bit8bytes/gearberg/internal/templates/pages" pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" ) +type importUploadData struct { + OrgID string + Error string +} + const importMaxBytes = 32 << 20 // 32 MiB +// getImportTemplate serves the template CSV for download. +func (app *application) getImportTemplate(w http.ResponseWriter, r *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.WriteHeader(http.StatusOK) + _, _ = w.Write(imports.TemplateCSV) + return nil +} + // getImport serves the upload form. func (app *application) getImport(w http.ResponseWriter, r *http.Request) *httperr.Error { - // TODO: render upload page template - return nil + orgID := r.PathValue("org_id") + tmpl := app.html.TemplateData(r) + tmpl.Data = importUploadData{OrgID: orgID} + return app.html.Render(w, r, http.StatusOK, pages.ImportUpload, tmpl) } // postImport parses the uploaded file, creates an import session, and redirects diff --git a/cmd/web/routes.go b/cmd/web/routes.go index 78e8bc1..aa8f863 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -82,6 +82,7 @@ func (app *application) routes() (http.Handler, error) { // Import mux.Handle("GET /orgs/{org_id}/import", app.withLogin(app.withPermission(app.html.Handle(app.getImport)))) + mux.Handle("GET /orgs/{org_id}/import/template", app.withLogin(app.withPermission(app.html.Handle(app.getImportTemplate)))) mux.Handle("POST /orgs/{org_id}/import", app.withLogin(app.withPermission(app.html.Handle(app.postImport)))) // Equipment diff --git a/internal/import/csv.go b/internal/import/csv.go index 6998de9..d54bd94 100644 --- a/internal/import/csv.go +++ b/internal/import/csv.go @@ -17,12 +17,16 @@ package imports import ( "context" + _ "embed" "fmt" "io" pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" ) +//go:embed template.csv +var TemplateCSV []byte + // CSVReader adapts pkg/csv.Reader to the imports.Reader and imports.Inspector interfaces. type CSVReader struct { r pkgcsv.Reader diff --git a/internal/templates/pages/equipment/index.tmpl b/internal/templates/pages/equipment/index.tmpl index 1052dc7..3a054f9 100644 --- a/internal/templates/pages/equipment/index.tmpl +++ b/internal/templates/pages/equipment/index.tmpl @@ -22,7 +22,7 @@