diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index 79f8fc9..f8aa24d 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -12,16 +12,16 @@ on: jobs: # nixos/web.nix uses buildGoModule which compiles the Go binary (no separate build job needed) - # and runs `go test -short` via doCheck = true (no separate test job needed). + # and runs `go test -short` via doCheck = true (no separate test job needed). # nixos/web.docker.nix builds the Docker image which includes the compiled binary. - build: + build-amd64: runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install Nix uses: cachix/install-nix-action@v31 @@ -39,9 +39,34 @@ jobs: - name: Build binary run: nix build .#default - - name: Build Docker image + - name: Build Docker image (amd64) run: nix build .#docker-amd64 - - - name: Build Docker image + + build-arm64: + runs-on: ubuntu-24.04-arm + timeout-minutes: 15 + permissions: + contents: read + + steps: + - uses: actions/checkout@v7 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Cache Nix store + uses: nix-community/cache-nix-action@v7 + with: + primary-key: nix-${{ runner.os }}-arm64-${{ hashFiles('flake.lock', '**/*.nix', '**/*.go') }} + restore-prefixes-first-match: nix-${{ runner.os }}-arm64- + gc-max-store-size-linux: 5368709120 # 5 GB + + - name: Build binary + run: nix build .#default + + - name: Build Docker image (arm64) run: nix build .#docker-arm64 diff --git a/cmd/web/handlers:equipment.go b/cmd/web/handlers:equipment.go index e84f319..346b56d 100644 --- a/cmd/web/handlers:equipment.go +++ b/cmd/web/handlers:equipment.go @@ -71,16 +71,17 @@ func (app *application) getEquipmentPartOfFragment(w http.ResponseWriter, r *htt } type equipmentData struct { - OrgID string - Categories []categories.Category - Inventories []equipment.Equipment - Filtered bool - Query string - Category string - Sort string - PageBaseURL template.URL - PrintURL template.URL - Pagination pagination.Metadata + OrgID string + Categories []categories.Category + Inventories []equipment.Equipment + Filtered bool + Query string + Category string + Sort string + PageBaseURL template.URL + PrintURL template.URL + Pagination pagination.Metadata + ImportSessionID string } func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *httperr.Error { @@ -117,18 +118,24 @@ func (app *application) getEquipment(w http.ResponseWriter, r *http.Request) *ht return httperr.InternalServerError(err) } + var importSessionID string + if session, err := app.services.equipmentImports.GetStagedSession(ctx, id); err == nil { + importSessionID = session.ID + } + tmpl := app.html.TemplateData(r) tmpl.Data = equipmentData{ - OrgID: id, - Categories: cats, - Inventories: items, - Filtered: query != "" || category != "", - Query: query, - Category: category, - Sort: sort, - PageBaseURL: template.URL(equipmentPageURL(id, query, category, sort)), // #nosec G203 - PrintURL: template.URL(equipmentPrintURL(id, query, category, sort)), // #nosec G203 - Pagination: meta, + OrgID: id, + Categories: cats, + Inventories: items, + Filtered: query != "" || category != "", + Query: query, + Category: category, + Sort: sort, + PageBaseURL: template.URL(equipmentPageURL(id, query, category, sort)), // #nosec G203 + PrintURL: template.URL(equipmentPrintURL(id, query, category, sort)), // #nosec G203 + Pagination: meta, + ImportSessionID: importSessionID, } // HTMX live-search: return only the results fragment so the page URL 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/handlers:export.go b/cmd/web/handlers:export.go new file mode 100644 index 0000000..2cb053d --- /dev/null +++ b/cmd/web/handlers:export.go @@ -0,0 +1,31 @@ +// 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" + + "github.com/bit8bytes/gearberg/internal/httperr" +) + +func (app *application) getExport(w http.ResponseWriter, r *http.Request) *httperr.Error { + orgID := r.PathValue("org_id") + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="gearberg-export.csv"`) + if err := app.services.equipment.ExportCSV(r.Context(), w, orgID); err != nil { + return httperr.InternalServerError(err) + } + return nil +} diff --git a/cmd/web/handlers:import.go b/cmd/web/handlers:import.go new file mode 100644 index 0000000..3d186f2 --- /dev/null +++ b/cmd/web/handlers:import.go @@ -0,0 +1,200 @@ +// 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/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/bit8bytes/gearberg/internal/flash" + "github.com/bit8bytes/gearberg/internal/httperr" + imports "github.com/bit8bytes/gearberg/internal/import" + "github.com/bit8bytes/gearberg/internal/templates/pages" +) + +const importMaxBytes = 32 << 20 // 32 MiB + +type importReviewRow struct { + ID string + RowNumber int64 + Name string + Status string + ErrorMessage string + Action string +} + +type importReviewData struct { + OrgID string + SessionID string + Rows []importReviewRow + CountReady int + CountPending int + CountError int + Step int +} + +// getImportTemplate serves the template CSV for download. +func (app *application) getImportTemplate(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.WriteHeader(http.StatusOK) + _, _ = w.Write(imports.TemplateCSV) + return nil +} + +// getImport serves the upload form, or redirects to the review page if a staged session exists. +func (app *application) getImport(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + + session, err := app.services.equipmentImports.GetStagedSession(ctx, orgID) + if err == nil { + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(session.ID), http.StatusSeeOther) + return nil + } + if !errors.Is(err, imports.ErrNotFound) { + return httperr.InternalServerError(err) + } + + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther) + return nil +} + +// postImport parses the uploaded file, creates an import session, and redirects +// to the review page. +func (app *application) postImport(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + + if _, err := app.services.equipmentImports.GetStagedSession(ctx, orgID); err == nil { + return httperr.BadRequest(fmt.Errorf("an import session is already in progress")) + } + + if err := r.ParseMultipartForm(importMaxBytes); err != nil { //nolint:gosec // bound is the importMaxBytes constant + 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 func() { _ = file.Close() }() + + session, err := app.services.equipmentImports.StartSession(ctx, orgID, imports.FormatCSV, file) + if err != nil { + return httperr.InternalServerError(err) + } + + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(session.ID), http.StatusSeeOther) + return nil +} + +// postImportDelete deletes the current staged session and redirects to the upload form. +func (app *application) postImportDelete(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + sessionID := r.PathValue("id") + + if err := app.services.equipmentImports.Delete(ctx, sessionID); err != nil { + return httperr.InternalServerError(err) + } + + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import", http.StatusSeeOther) + return nil +} + +// getImportReview shows staged rows for user review. +func (app *application) getImportReview(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + sessionID := r.PathValue("id") + + rows, err := app.services.equipmentImports.ListData(ctx, sessionID) + if err != nil { + return httperr.InternalServerError(err) + } + + data := importReviewData{ + OrgID: orgID, + SessionID: sessionID, + Rows: make([]importReviewRow, 0, len(rows)), + Step: 3, + } + + for _, row := range rows { + var fields map[string]string + _ = json.Unmarshal([]byte(row.Data), &fields) + name := fields["Name"] + + switch row.Status { + case imports.RowStatusError: + data.CountError++ + case imports.RowStatusValid: + if row.Action != imports.ActionSkip { + data.CountReady++ + } + default: + data.CountPending++ + } + + data.Rows = append(data.Rows, importReviewRow{ + ID: row.ID, + RowNumber: row.RowNumber, + Name: name, + Status: string(row.Status), + ErrorMessage: row.ErrorMessage, + Action: string(row.Action), + }) + } + + tmpl := app.html.TemplateData(r) + tmpl.Data = data + return app.html.Render(w, r, http.StatusOK, pages.ImportReview, tmpl) +} + +// postImportReview applies a single row decision (create/skip) and redirects back. +func (app *application) postImportReview(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + sessionID := r.PathValue("id") + + rowID := r.FormValue("row_id") + action := imports.Action(r.FormValue("action")) + + if err := app.services.equipmentImports.Review(ctx, []imports.Decision{{RowID: rowID, Action: action}}); err != nil { + return httperr.InternalServerError(err) + } + + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/import/"+url.PathEscape(sessionID), http.StatusSeeOther) + return nil +} + +// postImportConfirm commits the import and redirects to the equipment list. +func (app *application) postImportConfirm(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + sessionID := r.PathValue("id") + + if err := app.services.equipmentImports.Commit(ctx, sessionID); err != nil { + return httperr.InternalServerError(err) + } + + app.flash.Put(ctx, "Equipment imported successfully.", flash.Success) + http.Redirect(w, r, "/orgs/"+url.PathEscape(orgID)+"/equipment", http.StatusSeeOther) + return nil +} diff --git a/cmd/web/main.go b/cmd/web/main.go index bdde763..205480c 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -27,6 +27,7 @@ import ( "syscall" "time" + "github.com/bit8bytes/gearberg/internal/flash" htmlpkg "github.com/bit8bytes/gearberg/internal/html" "github.com/bit8bytes/gearberg/internal/templates" "github.com/bit8bytes/gearberg/internal/templates/pages" @@ -51,6 +52,8 @@ type application struct { services *services + flash flash.Manager + // oidcProviders is keyed by provider name. Providers are configured at // startup because OIDC discovery is a network call that should not happen per-request. oidcProviders map[string]*oidcProvider @@ -114,8 +117,6 @@ func runServe(args []string) error { return fmt.Errorf("load templates: %w", err) } - html := htmlpkg.New(log, base, cache, revision) - db, err := setupDatabase(ctx, options) if err != nil { return fmt.Errorf("setup database: %w", err) @@ -133,6 +134,9 @@ func runServe(args []string) error { return fmt.Errorf("setup session manager: %w", err) } sessionManager := setupSessionManager(scsMgr) + flashManager := flash.NewSessionManager(sessionManager) + + html := htmlpkg.New(log, base, cache, revision, htmlpkg.WithFlashManager(flashManager)) mailer := setupMailer(options, log) @@ -156,6 +160,7 @@ func runServe(args []string) error { html: html, db: db, session: sessionManager, + flash: flashManager, services: services, oidcProviders: oidcProviders, } diff --git a/cmd/web/routes.go b/cmd/web/routes.go index 4609744..34ed866 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -80,14 +80,19 @@ 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("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)))) + mux.Handle("GET /orgs/{org_id}/import/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getImportReview)))) + mux.Handle("POST /orgs/{org_id}/import/{id}/review", app.withLogin(app.withPermission(app.html.Handle(app.postImportReview)))) + mux.Handle("POST /orgs/{org_id}/import/{id}/confirm", app.withLogin(app.withPermission(app.html.Handle(app.postImportConfirm)))) + mux.Handle("POST /orgs/{org_id}/import/{id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postImportDelete)))) + // Equipment mux.Handle("GET /orgs/{org_id}/equipment", app.withLogin(app.withPermission(app.html.Handle(app.getEquipment)))) + mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.withPermission(app.html.Handle(app.getExport)))) 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..0fdc3aa 100644 --- a/cmd/web/setup.go +++ b/cmd/web/setup.go @@ -38,8 +38,8 @@ 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" + 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" @@ -122,6 +122,7 @@ func setupSCS(db *sql.DB) (*scs.SessionManager, error) { // sessionManager abstracts the SCS session store for testing and dependency inversion. type sessionManager interface { GetString(ctx context.Context, key string) string + PopString(ctx context.Context, key string) string Put(ctx context.Context, key string, val any) Destroy(ctx context.Context) error LoadAndSave(next http.Handler) http.Handler @@ -140,6 +141,10 @@ func (s scsSession) GetString(ctx context.Context, key string) string { return s.mgr.GetString(ctx, key) } +func (s scsSession) PopString(ctx context.Context, key string) string { + return s.mgr.PopString(ctx, key) +} + func (s scsSession) Put(ctx context.Context, key string, val any) { s.mgr.Put(ctx, key, val) } @@ -233,7 +238,7 @@ type services struct { manufacturers *manufacturers.Service locations *locations.Service equipment *equipment.Service - equipmentImports *invimports.Service + equipmentImports *imports.Service storageManager *storage.Manager } @@ -268,8 +273,10 @@ 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) + equipmentImportPipeline := equipment.NewImportPipeline(inventorySvc, equipmentcategoriesSvc, manufacturersSvc, locationsSvc) + + importsRepo := imports.NewRepository(db) + equipmentImportsSvc := imports.NewService(db, importsRepo, equipmentImportPipeline, equipmentImportPipeline, "equipment") store, err := storage.Open("local", opts.StorageDSN, logger) if err != nil { diff --git a/cmd/web/testutils_test.go b/cmd/web/testutils_test.go index 2eb0b20..be26e2b 100644 --- a/cmd/web/testutils_test.go +++ b/cmd/web/testutils_test.go @@ -71,13 +71,13 @@ func newTestServer(h http.Handler) *testServer { return &testServer{ts} } -func (ts *testServer) signout(t *testing.T) { +func (ts *testServer) signout(t *testing.T) { //nolint:unused if code, _, _ := ts.postForm(t, "/signout", url.Values{}); code != http.StatusSeeOther { t.Fatalf("signout failed: expected redirect, got status %d", code) } } -func (ts *testServer) signin(t *testing.T, email, password string) { +func (ts *testServer) signin(t *testing.T, email, password string) { //nolint:unused form := url.Values{} form.Add("email", email) form.Add("password", password) @@ -86,7 +86,7 @@ func (ts *testServer) signin(t *testing.T, email, password string) { } } -func (ts *testServer) get(t *testing.T, urlPath string) (code int, header http.Header, body []byte) { +func (ts *testServer) get(t *testing.T, urlPath string) (code int, header http.Header, body []byte) { //nolint:unused req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+urlPath, http.NoBody) if err != nil { t.Fatal(err) @@ -106,7 +106,7 @@ func (ts *testServer) get(t *testing.T, urlPath string) (code int, header http.H return rs.StatusCode, rs.Header, body } -func (ts *testServer) postForm(tb testing.TB, urlPath string, form url.Values) (code int, header http.Header, body []byte) { +func (ts *testServer) postForm(tb testing.TB, urlPath string, form url.Values) (code int, header http.Header, body []byte) { //nolint:unused req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+urlPath, strings.NewReader(form.Encode())) if err != nil { tb.Fatal(err) @@ -135,7 +135,7 @@ type testUser struct { ts *testServer } -func (u *testUser) signin(t *testing.T) { +func (u *testUser) signin(t *testing.T) { //nolint:unused t.Helper() // Always clear any active session first. The /signin route is wrapped with // the guest middleware, which silently redirects authenticated users to /home @@ -145,13 +145,13 @@ func (u *testUser) signin(t *testing.T) { u.ts.signin(t, u.email, u.password) } -func (u *testUser) signout(t *testing.T) { +func (u *testUser) signout(t *testing.T) { //nolint:unused t.Helper() u.ts.signout(t) } // postFile uploads a single file as a multipart/form-data POST request. -func (ts *testServer) postFile(t *testing.T, urlPath, fieldName, fileName string, content []byte) (code int, header http.Header, body []byte) { +func (ts *testServer) postFile(t *testing.T, urlPath, fieldName, fileName string, content []byte) (code int, header http.Header, body []byte) { //nolint:unused t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css index 3f2b8cc..a063139 100644 --- a/internal/assets/dist/index.css +++ b/internal/assets/dist/index.css @@ -5687,6 +5687,12 @@ gap: calc(0.25rem * 2); } } + .mt-0 { + margin-top: calc(var(--spacing) * 0); + } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -6524,6 +6530,14 @@ width: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6); } + .size-8 { + width: calc(var(--spacing) * 8); + height: calc(var(--spacing) * 8); + } + .size-10 { + width: calc(var(--spacing) * 10); + height: calc(var(--spacing) * 10); + } .status-lg { @layer daisyui.l1.l2 { width: calc(0.25rem * 3); @@ -6602,6 +6616,12 @@ .min-h-24 { min-height: calc(var(--spacing) * 24); } + .min-h-40 { + min-height: calc(var(--spacing) * 40); + } + .min-h-52 { + min-height: calc(var(--spacing) * 52); + } .min-h-\[calc\(100vh-4rem\)\] { min-height: calc(100vh - 4rem); } @@ -7769,6 +7789,12 @@ .bg-base-200 { background-color: var(--color-base-200); } + .bg-base-200\/40 { + background-color: var(--color-base-200); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-base-200) 40%, transparent); + } + } .bg-base-200\/50 { background-color: var(--color-base-200); @supports (color: color-mix(in lab, red, red)) { @@ -8093,6 +8119,9 @@ } } } + .p-0 { + padding: calc(var(--spacing) * 0); + } .p-1 { padding: calc(var(--spacing) * 1); } @@ -8246,6 +8275,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } .px-10 { padding-inline: calc(var(--spacing) * 10); } @@ -8320,6 +8352,9 @@ .pb-4 { padding-bottom: calc(var(--spacing) * 4); } + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); + } .pl-2 { padding-left: calc(var(--spacing) * 2); } @@ -10053,6 +10088,16 @@ --tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); } } + .hover\:border-primary\/40 { + &:hover { + @media (hover: hover) { + border-color: var(--color-primary); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-primary) 40%, transparent); + } + } + } + } .hover\:bg-base-200 { &:hover { @media (hover: hover) { @@ -10070,6 +10115,16 @@ } } } + .hover\:bg-base-200\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--color-base-200); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-base-200) 80%, transparent); + } + } + } + } .hover\:underline { &:hover { @media (hover: hover) { diff --git a/internal/assets/dist/index.js b/internal/assets/dist/index.js index 6b7dfb4..e6add92 100644 --- a/internal/assets/dist/index.js +++ b/internal/assets/dist/index.js @@ -319,4 +319,11 @@ document.addEventListener("click", (e) => { btn.textContent = isPassword ? "Hide" : "Show"; btn.setAttribute("aria-pressed", isPassword ? "true" : "false"); btn.setAttribute("aria-label", isPassword ? "Hide password" : "Show password"); +}); + +// Auto-dismiss a toast after N seconds +// Trigger:
+document.querySelectorAll("[data-dismiss-after]").forEach((el) => { + const delay = (parseInt(el.dataset.dismissAfter, 10) || 4) * 1000; + setTimeout(() => el.remove(), delay); }); \ No newline at end of file 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/00025_import_sessions.sql b/internal/database/migrations/sqlite/00025_import_sessions.sql new file mode 100644 index 0000000..3554b2c --- /dev/null +++ b/internal/database/migrations/sqlite/00025_import_sessions.sql @@ -0,0 +1,23 @@ +-- +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 DEFAULT 'draft' + CHECK (status IN ( + 'draft', -- uploaded, awaiting column mapping + 'mapped', -- mappings saved, awaiting validation + 'ready', -- validated, awaiting user commit + 'committed' -- import written to inventory + )), + target_entity TEXT NOT NULL, -- equipment + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + 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/00026_import_data.sql b/internal/database/migrations/sqlite/00026_import_data.sql new file mode 100644 index 0000000..fdda67c --- /dev/null +++ b/internal/database/migrations/sqlite/00026_import_data.sql @@ -0,0 +1,22 @@ +-- +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 '{}' CHECK (json_valid(data)), + status TEXT NOT NULL DEFAULT 'new' + CHECK (status IN ('new', 'valid', 'error')), + error_message TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL DEFAULT 'pending' + CHECK (action IN ('pending', 'create', 'skip')), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE (session_id, row_number) +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS import_data; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/00027_import_mappings.sql b/internal/database/migrations/sqlite/00027_import_mappings.sql new file mode 100644 index 0000000..41a798a --- /dev/null +++ b/internal/database/migrations/sqlite/00027_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/internal/database/queries/gen/equipment/equipment.sql.go b/internal/database/queries/gen/equipment/equipment.sql.go index cb8dd26..add2df3 100644 --- a/internal/database/queries/gen/equipment/equipment.sql.go +++ b/internal/database/queries/gen/equipment/equipment.sql.go @@ -208,6 +208,104 @@ func (q *Queries) Delete(ctx context.Context, id string) error { return err } +const export = `-- name: Export :many +SELECT + e.id, + e.name, + COALESCE(tt.name, '') AS tracking_type, + COALESCE(ut.name, '') AS usage_type, + COALESCE(ec.name, '') AS category, + COALESCE(em.name, '') AS manufacturer, + COALESCE(wl.name, '') AS location, + COALESCE(et.name, '') AS equipment_type, + e.rental_price, + e.resale_price, + e.notes, + e.weight_g, + e.width_mm, + e.height_mm, + e.depth_mm, + e.voltage_mv, + e.current_ma, + e.power_mw, + e.wire_gauge_mm2_x100 +FROM equipment e +LEFT JOIN equipment_categories ec ON ec.id = e.category_id +LEFT JOIN equipment_manufacturers em ON em.id = e.manufacturer_id +LEFT JOIN warehouse_locations wl ON wl.id = e.location_id +LEFT JOIN equipment_types et ON et.id = e.equipment_type_id +LEFT JOIN tracking_types tt ON tt.id = e.tracking_type_id +LEFT JOIN usage_types ut ON ut.id = e.usage_type_id +WHERE e.org_id = ?1 + AND e.is_archived = 0 +ORDER BY ec.name ASC, e.name ASC +` + +type ExportRow struct { + ID string + Name string + TrackingType string + UsageType string + Category string + Manufacturer string + Location string + EquipmentType string + RentalPrice *units.Cents + ResalePrice *units.Cents + Notes sql.NullString + WeightG *units.Grams + WidthMm *units.Millimeters + HeightMm *units.Millimeters + DepthMm *units.Millimeters + VoltageMv *units.Millivolts + CurrentMa *units.Milliamps + PowerMw *units.Milliwatts + WireGaugeMm2X100 *units.WireGauge +} + +func (q *Queries) Export(ctx context.Context, orgID string) ([]ExportRow, error) { + rows, err := q.db.QueryContext(ctx, export, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ExportRow + for rows.Next() { + var i ExportRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TrackingType, + &i.UsageType, + &i.Category, + &i.Manufacturer, + &i.Location, + &i.EquipmentType, + &i.RentalPrice, + &i.ResalePrice, + &i.Notes, + &i.WeightG, + &i.WidthMm, + &i.HeightMm, + &i.DepthMm, + &i.VoltageMv, + &i.CurrentMa, + &i.PowerMw, + &i.WireGaugeMm2X100, + ); 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 getByID = `-- name: GetByID :one SELECT e.id, diff --git a/internal/database/queries/gen/equipment/querier.go b/internal/database/queries/gen/equipment/querier.go index 950de1a..3aad71b 100644 --- a/internal/database/queries/gen/equipment/querier.go +++ b/internal/database/queries/gen/equipment/querier.go @@ -12,6 +12,7 @@ type Querier interface { CountByOrgID(ctx context.Context, orgID string) (int64, error) Create(ctx context.Context, arg CreateParams) (CreateRow, error) Delete(ctx context.Context, id string) error + Export(ctx context.Context, orgID string) ([]ExportRow, error) GetByID(ctx context.Context, id string) (GetByIDRow, error) List(ctx context.Context, arg ListParams) ([]ListRow, error) ListBySerialNumber(ctx context.Context, arg ListBySerialNumberParams) ([]ListBySerialNumberRow, error) 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/gen/equipmentimports/db.go b/internal/database/queries/gen/importdata/db.go similarity index 95% rename from internal/database/queries/gen/equipmentimports/db.go rename to internal/database/queries/gen/importdata/db.go index 6d8a203..119dea5 100644 --- a/internal/database/queries/gen/equipmentimports/db.go +++ b/internal/database/queries/gen/importdata/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.31.1 -package equipmentimports +package importdata import ( "context" 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..e8e9aa0 --- /dev/null +++ b/internal/database/queries/gen/importdata/import_data.sql.go @@ -0,0 +1,219 @@ +// 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", updated_at, created_at 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, + &i.UpdatedAt, + &i.CreatedAt, + ) + 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", updated_at, created_at +` + +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, + &i.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const listData = `-- name: ListData :many +SELECT id, session_id, row_number, data, status, error_message, "action", updated_at, created_at 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, + &i.UpdatedAt, + &i.CreatedAt, + ); 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", updated_at, created_at 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, + &i.UpdatedAt, + &i.CreatedAt, + ); 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 = ?, updated_at = unixepoch() +WHERE id = ? +RETURNING id, session_id, row_number, data, status, error_message, "action", updated_at, created_at +` + +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, + &i.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const updateDataStatus = `-- name: UpdateDataStatus :one +UPDATE import_data +SET status = ?, error_message = ?, updated_at = unixepoch() +WHERE id = ? +RETURNING id, session_id, row_number, data, status, error_message, "action", updated_at, created_at +` + +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, + &i.UpdatedAt, + &i.CreatedAt, + ) + 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..dd0d16e --- /dev/null +++ b/internal/database/queries/gen/importdata/models.go @@ -0,0 +1,17 @@ +// 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 + UpdatedAt int64 + CreatedAt int64 +} 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..03a06d9 --- /dev/null +++ b/internal/database/queries/gen/importsessions/import_sessions.sql.go @@ -0,0 +1,129 @@ +// 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, updated_at, 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.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const getStagedSession = `-- name: GetStagedSession :one +SELECT id, org_id, format, status, target_entity, updated_at, created_at FROM import_sessions +WHERE org_id = ? AND status = ? +ORDER BY created_at DESC +LIMIT 1 +` + +type GetStagedSessionParams struct { + OrgID string + Status string +} + +func (q *Queries) GetStagedSession(ctx context.Context, arg GetStagedSessionParams) (ImportSession, error) { + row := q.db.QueryRowContext(ctx, getStagedSession, arg.OrgID, arg.Status) + var i ImportSession + err := row.Scan( + &i.ID, + &i.OrgID, + &i.Format, + &i.Status, + &i.TargetEntity, + &i.UpdatedAt, + &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, updated_at, 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.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const updateSessionStatus = `-- name: UpdateSessionStatus :one +UPDATE import_sessions +SET status = ?, updated_at = unixepoch() +WHERE id = ? +RETURNING id, org_id, format, status, target_entity, updated_at, 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.UpdatedAt, + &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..0b41541 --- /dev/null +++ b/internal/database/queries/gen/importsessions/models.go @@ -0,0 +1,15 @@ +// 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 + UpdatedAt int64 + 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..f5daf3e --- /dev/null +++ b/internal/database/queries/gen/importsessions/querier.go @@ -0,0 +1,19 @@ +// 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) + GetStagedSession(ctx context.Context, arg GetStagedSessionParams) (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/equipment.sql b/internal/database/queries/sqlite/equipment.sql index c7d7543..714f335 100644 --- a/internal/database/queries/sqlite/equipment.sql +++ b/internal/database/queries/sqlite/equipment.sql @@ -242,6 +242,38 @@ WHERE id = sqlc.arg(id); DELETE FROM equipment WHERE id = ?; +-- name: Export :many +SELECT + e.id, + e.name, + COALESCE(tt.name, '') AS tracking_type, + COALESCE(ut.name, '') AS usage_type, + COALESCE(ec.name, '') AS category, + COALESCE(em.name, '') AS manufacturer, + COALESCE(wl.name, '') AS location, + COALESCE(et.name, '') AS equipment_type, + e.rental_price, + e.resale_price, + e.notes, + e.weight_g, + e.width_mm, + e.height_mm, + e.depth_mm, + e.voltage_mv, + e.current_ma, + e.power_mw, + e.wire_gauge_mm2_x100 +FROM equipment e +LEFT JOIN equipment_categories ec ON ec.id = e.category_id +LEFT JOIN equipment_manufacturers em ON em.id = e.manufacturer_id +LEFT JOIN warehouse_locations wl ON wl.id = e.location_id +LEFT JOIN equipment_types et ON et.id = e.equipment_type_id +LEFT JOIN tracking_types tt ON tt.id = e.tracking_type_id +LEFT JOIN usage_types ut ON ut.id = e.usage_type_id +WHERE e.org_id = sqlc.arg(org_id) + AND e.is_archived = 0 +ORDER BY ec.name ASC, e.name ASC; + -- name: UpdateArchived :exec UPDATE equipment SET 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/database/queries/sqlite/import_data.sql b/internal/database/queries/sqlite/import_data.sql new file mode 100644 index 0000000..ca125b8 --- /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 = ?, updated_at = unixepoch() +WHERE id = ? +RETURNING *; + +-- name: UpdateDataStatus :one +UPDATE import_data +SET status = ?, error_message = ?, updated_at = unixepoch() +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..681f393 --- /dev/null +++ b/internal/database/queries/sqlite/import_sessions.sql @@ -0,0 +1,24 @@ +-- 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 = ?, updated_at = unixepoch() +WHERE id = ? +RETURNING *; + +-- name: GetStagedSession :one +SELECT * FROM import_sessions +WHERE org_id = ? AND status = ? +ORDER BY created_at DESC +LIMIT 1; + +-- name: DeleteSession :exec +DELETE FROM import_sessions +WHERE id = ?; diff --git a/internal/equipment/export.go b/internal/equipment/export.go new file mode 100644 index 0000000..9a82807 --- /dev/null +++ b/internal/equipment/export.go @@ -0,0 +1,135 @@ +// Copyright (C) 2026 Tobias Gleiter +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package equipment + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "strconv" + "time" + + "github.com/bit8bytes/gearberg/internal/equipment/tracking" + "github.com/bit8bytes/gearberg/internal/equipment/usage" +) + +// ExportRow is one CSV row matching the import template column order exactly. +type ExportRow struct { + Name string + EquipmentType string // Standard or Kit + TrackingType string // Bulk or Serialized + Usage string + Category string + Manufacturer string + Location string + RentalPrice string + ResalePrice string + Notes string + WeightKg string + WidthCm string + HeightCm string + DepthCm string + VoltageV string + CurrentA string + PowerW string + WireGauge string + Quantity string + // unit fields (serialized only) + UnitSerialNumber string + UnitManufacturerSerial string + UnitPurchasePrice string + UnitPurchasedAt string + NextInspectionAt string + UnitActive string + UnitRemark string +} + +var exportHeaders = []string{ + "Name", "Type", "Tracking 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", + "Unit Serial Number", "Unit Manufacturer Serial", "Unit Purchase Price", + "Unit Purchased At", "Next Inspection At", "Unit Active", "Unit Remark", +} + +// WriteExportCSV writes rows to w in import-template column order. +func WriteExportCSV(w io.Writer, rows []ExportRow) error { + cw := csv.NewWriter(w) + if err := cw.Write(exportHeaders); err != nil { + return fmt.Errorf("WriteExportCSV: write header: %w", err) + } + for _, r := range rows { + record := []string{ + r.Name, r.EquipmentType, r.TrackingType, r.Usage, r.Category, r.Manufacturer, r.Location, + r.RentalPrice, r.ResalePrice, r.Notes, + r.WeightKg, r.WidthCm, r.HeightCm, r.DepthCm, + r.VoltageV, r.CurrentA, r.PowerW, r.WireGauge, + r.Quantity, + r.UnitSerialNumber, r.UnitManufacturerSerial, r.UnitPurchasePrice, + r.UnitPurchasedAt, r.NextInspectionAt, r.UnitActive, r.UnitRemark, + } + if err := cw.Write(record); err != nil { + return fmt.Errorf("WriteExportCSV: write row: %w", err) + } + } + cw.Flush() + if err := cw.Error(); err != nil { + return fmt.Errorf("WriteExportCSV: flush: %w", err) + } + return nil +} + +// ExportCSV fetches all non-archived equipment for orgID and writes them as CSV to w. +func (s *Service) ExportCSV(ctx context.Context, w io.Writer, orgID string) error { + rows, err := s.repo.Export(ctx, orgID) + if err != nil { + return fmt.Errorf("ExportCSV: %w", err) + } + return WriteExportCSV(w, rows) +} + +func formatUnixDate(v *int64) string { + if v == nil { + return "" + } + return time.Unix(*v, 0).UTC().Format("2006-01-02") +} + +func equipmentTypeLabel(name string) string { + return ParseOrDefault(name).String() +} + +func formatActive(isActive int64) string { + if isActive == 1 { + return "true" + } + return "false" +} + +func usageLabel(name string) string { + return usage.Parse(name).Label() +} + +func trackingLabel(name string) string { + return tracking.Parse(name).Label() +} + +func formatQuantity(q int64) string { + return strconv.FormatInt(q, 10) +} diff --git a/internal/equipment/import.go b/internal/equipment/import.go new file mode 100644 index 0000000..94292d4 --- /dev/null +++ b/internal/equipment/import.go @@ -0,0 +1,246 @@ +// Copyright (C) 2026 Tobias Gleiter +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package equipment + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/bit8bytes/gearberg/internal/equipment/usage" + imports "github.com/bit8bytes/gearberg/internal/import" + "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) +} + +// ImportPipeline bundles the org-agnostic validation steps and commit handler +// for equipment imports. Build once at startup; orgID is supplied per request. +type ImportPipeline struct { + equip *Service + categories Upserter + manufacturers Upserter + locations Upserter +} + +// NewImportPipeline returns a stateless ImportPipeline. Wire it once in +// setupServices and inject it into the handler via the services struct. +func NewImportPipeline(equip *Service, cats, mfrs, locs Upserter) *ImportPipeline { + return &ImportPipeline{ + equip: equip, + categories: cats, + manufacturers: mfrs, + locations: locs, + } +} + +// StepsFor returns the validation steps bound to orgID for a single session. +func (p *ImportPipeline) StepsFor(orgID string) []imports.Step { + return []imports.Step{p.equip.conflictStep(orgID)} +} + +// Commit implements imports.CommitHandler. +func (p *ImportPipeline) Commit(ctx context.Context, orgID string, rows []imports.Row, mappings []imports.Mapping) error { + fieldFor := make(map[string]string, len(mappings)) + for _, m := range mappings { + fieldFor[m.SourceCol] = m.TargetField + } + + type serializedGroup struct { + fields map[string]string + units []map[string]string + } + bulkRows := make([]map[string]string, 0) + serializedByName := make(map[string]*serializedGroup) + var serializedOrder []string + + for _, row := range rows { + var raw map[string]string + if err := json.Unmarshal([]byte(row.Data), &raw); err != nil { + return fmt.Errorf("Commit: unmarshal row %d: %w", row.RowNumber, err) + } + fields := applyMappings(raw, fieldFor) + + typeLabel := strings.ToLower(strings.TrimSpace(fields["Tracking Type"])) + if typeLabel == "serialized" { + key := strings.ToLower(strings.TrimSpace(fields["Name"])) + if _, ok := serializedByName[key]; !ok { + serializedByName[key] = &serializedGroup{fields: fields} + serializedOrder = append(serializedOrder, key) + } + serializedByName[key].units = append(serializedByName[key].units, fields) + } else { + bulkRows = append(bulkRows, fields) + } + } + + for _, fields := range bulkRows { + if err := p.commitBulk(ctx, orgID, fields); err != nil { + return fmt.Errorf("Commit: bulk %q: %w", fields["Name"], err) + } + } + for _, key := range serializedOrder { + g := serializedByName[key] + if err := p.commitSerialized(ctx, orgID, g.fields, g.units); err != nil { + return fmt.Errorf("Commit: serialized %q: %w", g.fields["Name"], err) + } + } + return nil +} + +func (p *ImportPipeline) commitBulk(ctx context.Context, orgID string, fields map[string]string) error { + base, err := p.buildBase(ctx, orgID, fields) + if err != nil { + return err + } + qty := max(ParseQuantity(fields["Quantity"]), 1) + _, err = p.equip.Create(ctx, CreateEquipment{ + Base: base, + ItemType: "bulk", + TotalStock: qty, + }) + return err +} + +func (p *ImportPipeline) commitSerialized(ctx context.Context, orgID string, fields map[string]string, unitFields []map[string]string) error { + base, err := p.buildBase(ctx, orgID, fields) + if err != nil { + return err + } + itemID := uid.New() + unitList := make([]CreateUnit, 0, len(unitFields)) + for _, uf := range unitFields { + sn := strings.TrimSpace(uf["Unit Serial Number"]) + if sn == "" { + sn = serial.New() + } + isActive := !strings.EqualFold(uf["Unit Active"], "false") && uf["Unit Active"] != "0" + var purchasePrice *units.Cents + if p := units.ParseCents(uf["Unit Purchase Price"]); p != nil { + purchasePrice = p + } + unitList = append(unitList, CreateUnit{ + ID: uid.New(), + OrgID: orgID, + EquipmentID: itemID, + SerialNumber: sn, + ManufacturerSerialNumber: uf["Unit Manufacturer Serial"], + Remark: uf["Unit Remark"], + PurchasePrice: purchasePrice, + PurchasedAt: ParseDate(uf["Unit Purchased At"]), + NextInspectionAt: ParseDate(uf["Next Inspection At"]), + IsActive: isActive, + }) + } + _, err = p.equip.CreateWithUnits(ctx, base, unitList) + return err +} + +func (p *ImportPipeline) buildBase(ctx context.Context, orgID string, fields map[string]string) (Base, error) { + catID, err := p.categories.Upsert(ctx, orgID, fields["Category"]) + if err != nil { + return Base{}, fmt.Errorf("category %q: %w", fields["Category"], err) + } + var mfrID string + if name := strings.TrimSpace(fields["Manufacturer"]); name != "" { + mfrID, err = p.manufacturers.Upsert(ctx, orgID, name) + if err != nil { + return Base{}, fmt.Errorf("manufacturer %q: %w", name, err) + } + } + var locID string + if name := strings.TrimSpace(fields["Location"]); name != "" { + locID, err = p.locations.Upsert(ctx, orgID, name) + if err != nil { + return Base{}, fmt.Errorf("location %q: %w", name, err) + } + } + return Base{ + OrgID: orgID, + Name: fields["Name"], + UsageTypeID: usage.Rental.ID(), + CategoryID: catID, + ManufacturerID: mfrID, + LocationID: locID, + Notes: fields["Notes"], + EquipmentType: ParseOrDefault(strings.ToLower(strings.TrimSpace(fields["Type"]))), + Pricing: Pricing{ + RentalPrice: units.ParseCents(fields["Rental Price"]), + }, + Properties: Properties{ + Weight: units.ParseGrams(fields["Weight (kg)"]), + Width: units.ParseMillimeters(fields["Width (cm)"]), + Height: units.ParseMillimeters(fields["Height (cm)"]), + Depth: units.ParseMillimeters(fields["Depth (cm)"]), + }, + }, nil +} + +// conflictStep returns a Step bound to orgID that marks rows whose "Name" +// already exists in inventory as an error. +// Serialized rows sharing a name within the same batch are not double-flagged. +func (s *Service) conflictStep(orgID string) imports.Step { + return func(ctx context.Context, rows []imports.Row) ([]imports.Row, error) { + existing, err := s.ListNames(ctx, orgID) + if err != nil { + return nil, fmt.Errorf("conflictStep: list names: %w", err) + } + nameSet := make(map[string]struct{}, len(existing)) + for _, n := range existing { + nameSet[strings.ToLower(n)] = struct{}{} + } + + for i, row := range rows { + var fields map[string]string + if err := json.Unmarshal([]byte(row.Data), &fields); err != nil { + rows[i].Status = imports.RowStatusError + rows[i].ErrorMessage = fmt.Sprintf("invalid JSON: %v", err) + continue + } + + name := strings.ToLower(strings.TrimSpace(fields["Name"])) + if name == "" { + rows[i].Status = imports.RowStatusError + rows[i].ErrorMessage = "Name is required" + continue + } + + if _, conflict := nameSet[name]; conflict { + rows[i].Status = imports.RowStatusError + rows[i].ErrorMessage = fmt.Sprintf("equipment %q already exists in inventory", fields["Name"]) + } + } + return rows, nil + } +} + +func applyMappings(raw, fieldFor map[string]string) map[string]string { + out := make(map[string]string, len(raw)) + for col, val := range raw { + if target, ok := fieldFor[col]; ok { + out[target] = val + } else { + out[col] = val + } + } + return out +} diff --git a/internal/equipment/repository.go b/internal/equipment/repository.go index 477cba6..a214639 100644 --- a/internal/equipment/repository.go +++ b/internal/equipment/repository.go @@ -554,6 +554,64 @@ func (r *Repository) DeleteUnit(ctx context.Context, id string) error { return nil } +// Export returns all non-archived equipment for orgID as a flat list of ExportRows, +// one row per bulk item and one row per serialized unit. +func (r *Repository) Export(ctx context.Context, orgID string) ([]ExportRow, error) { + rows, err := r.equipment.Export(ctx, orgID) + if err != nil { + return nil, fmt.Errorf("Export: %w", err) + } + result := make([]ExportRow, 0, len(rows)) + for _, row := range rows { + base := ExportRow{ + Name: row.Name, + EquipmentType: equipmentTypeLabel(row.EquipmentType), + TrackingType: trackingLabel(row.TrackingType), + Usage: usageLabel(row.UsageType), + Category: row.Category, + Manufacturer: row.Manufacturer, + Location: row.Location, + RentalPrice: row.RentalPrice.String(), + ResalePrice: row.ResalePrice.String(), + Notes: database.String(row.Notes), + WeightKg: row.WeightG.String(), + WidthCm: row.WidthMm.String(), + HeightCm: row.HeightMm.String(), + DepthCm: row.DepthMm.String(), + VoltageV: row.VoltageMv.String(), + CurrentA: row.CurrentMa.String(), + PowerW: row.PowerMw.String(), + WireGauge: row.WireGaugeMm2X100.String(), + } + switch tracking.Parse(row.TrackingType) { + case tracking.Bulk: + bulk, err := r.bulkItems.GetByEquipmentID(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("Export: bulk items for %s: %w", row.ID, err) + } + base.Quantity = formatQuantity(bulk.Quantity) + result = append(result, base) + case tracking.Serialized: + serialized, err := r.serializedItems.ListByEquipmentID(ctx, row.ID) + if err != nil { + return nil, fmt.Errorf("Export: serialized items for %s: %w", row.ID, err) + } + for _, u := range serialized { + r := base + r.UnitSerialNumber = u.SerialNumber + r.UnitManufacturerSerial = database.String(u.ManufacturerSerial) + r.UnitPurchasePrice = database.NullAs[units.Cents](u.PurchasePrice).String() + r.UnitPurchasedAt = formatUnixDate(database.Int64Ptr(u.PurchasedAt)) + r.NextInspectionAt = formatUnixDate(database.Int64Ptr(u.NextInspectionAt)) + r.UnitActive = formatActive(u.IsActive) + r.UnitRemark = database.String(u.Remark) + result = append(result, r) + } + } + } + return result, nil +} + // ListContent returns all content items for the equipment definition with equipmentID. func (r *Repository) ListContent(ctx context.Context, equipmentID string) ([]ContentItem, error) { rows, err := r.content.ListByEquipmentID(ctx, equipmentID) diff --git a/internal/equipment/service.go b/internal/equipment/service.go index fbb314c..c737245 100644 --- a/internal/equipment/service.go +++ b/internal/equipment/service.go @@ -101,6 +101,32 @@ func (s *Service) Create(ctx context.Context, c CreateEquipment) (*Equipment, er return item, nil } +// CreateWithUnits creates a serialized equipment item with explicitly provided +// units, preserving serial numbers supplied by the caller (e.g. from import data). +func (s *Service) CreateWithUnits(ctx context.Context, base Base, unitList []CreateUnit) (*Equipment, error) { + itemID := uid.New() + for i := range unitList { + unitList[i].EquipmentID = itemID + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("CreateWithUnits: %w", err) + } + defer func() { _ = tx.Rollback() }() + item, err := s.repo.CreateSerialized(ctx, tx, CreateSerializedEquipment{ + ID: itemID, + Base: base, + Units: unitList, + }) + if err != nil { + return nil, fmt.Errorf("CreateWithUnits: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("CreateWithUnits: %w", err) + } + return item, nil +} + // ListParams holds the parameters for listing equipment. type ListParams struct { OrgID string @@ -120,6 +146,20 @@ func (s *Service) List(ctx context.Context, p ListParams) ([]Equipment, paginati return items, meta, nil } +// ListNames returns the names of all equipment items belonging to orgID. +// Used by the import conflict-detection step. +func (s *Service) ListNames(ctx context.Context, orgID string) ([]string, error) { + items, _, err := s.repo.List(ctx, orgID, "", "", false, pagination.Filters{Page: 1, PageSize: math.MaxInt32}) + if err != nil { + return nil, fmt.Errorf("ListNames: %w", err) + } + names := make([]string, len(items)) + for i, item := range items { + names[i] = item.Name + } + return names, nil +} + // findByName returns the first equipment item in items whose name matches name, // or nil when no match is found. func findByName(items []Equipment, name string) *Equipment { 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/internal/flash/flash.go b/internal/flash/flash.go new file mode 100644 index 0000000..bb8c54c --- /dev/null +++ b/internal/flash/flash.go @@ -0,0 +1,44 @@ +// 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 flash provides one-shot notification messages stored in the session +// and rendered as toasts on the next page load (post-redirect-get pattern). +package flash + +import "context" + +// Type controls the DaisyUI alert style of a notification. +type Type string + +// Notification types mapped to DaisyUI alert styles. +const ( + Success Type = "success" + Error Type = "error" +) + +// Flash is a one-shot notification message displayed as a toast. +type Flash struct { + Message string + Type Type +} + +// Manager stores and retrieves flash notifications across requests. +type Manager interface { + // Put stores a flash notification to be shown on the next page load. + Put(ctx context.Context, msg string, t Type) + // Pop retrieves and removes the flash notification. + // Returns nil if no flash is set. + Pop(ctx context.Context) *Flash +} diff --git a/internal/flash/session.go b/internal/flash/session.go new file mode 100644 index 0000000..86cfba7 --- /dev/null +++ b/internal/flash/session.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 flash provides one-shot notification messages stored in the session. +package flash + +import "context" + +const ( + messageKey = "flash.message" + typeKey = "flash.type" +) + +// sessionStore is the subset of the SCS session manager used by SessionManager. +// *scs.SessionManager satisfies this interface. +type sessionStore interface { + Put(ctx context.Context, key string, val any) + PopString(ctx context.Context, key string) string +} + +// SessionManager is a flash.Manager backed by a session store. +type SessionManager struct { + store sessionStore +} + +// NewSessionManager returns a SessionManager backed by store. +func NewSessionManager(store sessionStore) *SessionManager { + return &SessionManager{store: store} +} + +// Put stores a flash notification to be shown on the next page load. +func (s *SessionManager) Put(ctx context.Context, msg string, t Type) { + s.store.Put(ctx, messageKey, msg) + s.store.Put(ctx, typeKey, string(t)) +} + +// Pop retrieves and removes the flash notification. Returns nil if no flash is set. +func (s *SessionManager) Pop(ctx context.Context) *Flash { + msg := s.store.PopString(ctx, messageKey) + if msg == "" { + return nil + } + return &Flash{ + Message: msg, + Type: Type(s.store.PopString(ctx, typeKey)), + } +} diff --git a/internal/html/html.go b/internal/html/html.go index f48fe08..ea06a7b 100644 --- a/internal/html/html.go +++ b/internal/html/html.go @@ -19,12 +19,14 @@ package html import ( "bytes" + "context" "fmt" "html/template" "log/slog" "net/http" "net/url" + "github.com/bit8bytes/gearberg/internal/flash" "github.com/bit8bytes/gearberg/internal/httperr" "github.com/bit8bytes/gearberg/internal/nonce" "github.com/bit8bytes/gearberg/internal/templates/pages" @@ -49,19 +51,41 @@ type TemplateData struct { // OrgID is the active organisation from the URL path, populated on org-scoped pages. // Empty on user-level pages (e.g. /settings/account). Used by the header partial. OrgID string + // Flash is a one-shot notification popped from the session and rendered as a toast. + Flash *flash.Flash } +// Option configures an HTML renderer. +type Option func(*HTML) + +// WithFlashManager wires a flash.Manager so that TemplateData automatically +// pops any pending notification into the Flash field. +func WithFlashManager(fm flash.Manager) Option { + return func(h *HTML) { h.flash = fm } +} + +// noopFlash is used when no flash manager is configured. +type noopFlash struct{} + +func (noopFlash) Put(_ context.Context, _ string, _ flash.Type) {} +func (noopFlash) Pop(_ context.Context) *flash.Flash { return nil } + // HTML renders HTML responses and adapts httperr.HandlerFunc into standard http.HandlerFunc. type HTML struct { logger *slog.Logger base *template.Template cache map[string]*template.Template revision string + flash flash.Manager } // New returns a Renderer backed by the given logger, template cache, and revision string. -func New(logger *slog.Logger, base *template.Template, cache map[string]*template.Template, revision string) *HTML { - return &HTML{logger: logger, base: base, cache: cache, revision: revision} +func New(logger *slog.Logger, base *template.Template, cache map[string]*template.Template, revision string, opts ...Option) *HTML { + h := &HTML{logger: logger, base: base, cache: cache, revision: revision, flash: noopFlash{}} + for _, o := range opts { + o(h) + } + return h } // Handle adapts an httperr.HandlerFunc into a standard http.HandlerFunc. When the handler @@ -132,5 +156,6 @@ func (rnd *HTML) TemplateData(r *http.Request) *TemplateData { Nonce: nonce.From(r.Context()), Revision: rnd.revision, OrgID: r.PathValue("org_id"), + Flash: rnd.flash.Pop(r.Context()), } } diff --git a/internal/import/csv.go b/internal/import/csv.go new file mode 100644 index 0000000..237d0bf --- /dev/null +++ b/internal/import/csv.go @@ -0,0 +1,68 @@ +// 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" + _ "embed" + "fmt" + "io" + + pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" +) + +// TemplateCSV is the downloadable CSV template for users to fill out before importing. +// +//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 +} + +func init() { + RegisterFormat(FormatCSV, &CSVReader{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 +} + +// InspectHeaders returns only the header row of the CSV without reading all records. +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 +} diff --git a/internal/import/model.go b/internal/import/model.go new file mode 100644 index 0000000..c50b9ed --- /dev/null +++ b/internal/import/model.go @@ -0,0 +1,124 @@ +// 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" + "fmt" +) + +// ErrNotFound is returned when a session or row is not found. +var ErrNotFound = errors.New("import: not found") + +// Format identifies the source file format. +type Format string + +// Supported import file formats. +const ( + FormatCSV Format = "csv" + FormatJSON Format = "json" +) + +var readers = map[Format]Reader{} + +// RegisterFormat registers a Reader for the given Format. +// Call from an init() function in the format's package. +func RegisterFormat(f Format, r Reader) { + readers[f] = r +} + +func readerFor(f Format) (Reader, error) { + r, ok := readers[f] + if !ok { + return nil, fmt.Errorf("import: no reader registered for format %q", f) + } + return r, nil +} + +// 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 + +// Import session lifecycle states. +const ( + StatusDraft Status = "draft" // uploaded, awaiting column mapping + StatusMapped Status = "mapped" // mappings saved, awaiting validation + StatusReady Status = "ready" // validated, awaiting user commit + StatusCommitted Status = "committed" // import written to inventory +) + +// Action is the user's decision for a single row. +type Action string + +// User decisions for a single import row. +const ( + ActionPending Action = "pending" + ActionCreate Action = "create" + ActionSkip Action = "skip" +) + +// RowStatus is the system's assessment of a single row. +type RowStatus string + +// System assessments for a single import row. +const ( + RowStatusNew RowStatus = "new" + RowStatusValid RowStatus = "valid" + RowStatusError RowStatus = "error" +) + +// 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..fd593c1 --- /dev/null +++ b/internal/import/repository.go @@ -0,0 +1,234 @@ +// 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), + } +} + +// InsertSessionTx inserts a new import session within an existing transaction. +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 +} + +// GetSession returns a session by ID, or ErrNotFound if none exists. +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 +} + +// GetStagedSession returns the active ready session for an org, or ErrNotFound if none exists. +func (r *Repository) GetStagedSession(ctx context.Context, orgID string) (Session, error) { + row, err := r.sessions.GetStagedSession(ctx, gensessions.GetStagedSessionParams{ + OrgID: orgID, + Status: string(StatusReady), + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrNotFound + } + return Session{}, fmt.Errorf("GetStagedSession: %w", err) + } + return sessionFromRecord(row), nil +} + +// DeleteSession deletes a session and cascades to its rows and mappings. +func (r *Repository) DeleteSession(ctx context.Context, id string) error { + if err := r.sessions.DeleteSession(ctx, id); err != nil { + return fmt.Errorf("DeleteSession: %w", err) + } + return nil +} + +// UpdateSessionStatus transitions a session to the given status and returns the updated record. +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 +} + +// InsertDataTx inserts a staged import row within an existing transaction. +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 +} + +// ListData returns all staged rows for a session. +func (r *Repository) ListData(ctx context.Context, sessionID string) ([]Row, error) { + recs, err := r.data.ListData(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("ListData: %w", err) + } + rows := make([]Row, len(recs)) + for i, rec := range recs { + rows[i] = dataFromRecord(rec) + } + return rows, nil +} + +// ListDataByAction returns staged rows filtered by the given action. +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 +} + +// UpdateDataStatus sets the validation status and error message for a row. +func (r *Repository) UpdateDataStatus(ctx context.Context, id string, status RowStatus, errMsg string) error { + _, err := r.data.UpdateDataStatus(ctx, gendata.UpdateDataStatusParams{ + Status: string(status), + ErrorMessage: errMsg, + ID: id, + }) + if err != nil { + return fmt.Errorf("UpdateDataStatus: %w", err) + } + return nil +} + +// UpdateDataAction sets the user-chosen action for a row and returns the updated record. +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 +} + +// InsertMappingTx inserts a column mapping within an existing transaction. +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 +} + +// ListMappings returns all column mappings for a session. +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..7944659 --- /dev/null +++ b/internal/import/service.go @@ -0,0 +1,267 @@ +// 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. Steps that need DB access close +// over their own dependencies. +type Step func(ctx context.Context, rows []Row) ([]Row, error) + +// CommitHandler writes committed rows to the target domain (e.g. equipment). +type CommitHandler interface { + Commit(ctx context.Context, orgID string, rows []Row, mappings []Mapping) error +} + +// StepProvider returns the validation steps for a given org. +type StepProvider interface { + StepsFor(orgID string) []Step +} + +// Service orchestrates the import pipeline. +type Service struct { + db *sql.DB + repo *Repository + steps StepProvider + handler CommitHandler + entity string +} + +// NewService returns a new Service. +func NewService(db *sql.DB, repo *Repository, steps StepProvider, handler CommitHandler, entity string) *Service { + return &Service{db: db, repo: repo, steps: steps, handler: handler, entity: entity} +} + +// StartSession parses the file, creates an import session, stores raw rows, +// runs validation steps, and returns the staged Session. +func (s *Service) StartSession(ctx context.Context, orgID string, format Format, r io.Reader) (Session, error) { + reader, err := readerFor(format) + if err != nil { + return Session{}, fmt.Errorf("StartSession: %w", err) + } + records, err := reader.Read(ctx, r) + if err != nil { + return Session{}, fmt.Errorf("StartSession: read: %w", err) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Session{}, fmt.Errorf("StartSession: begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + session, rows, err := s.stageSession(ctx, tx, orgID, format, records) + if err != nil { + return Session{}, fmt.Errorf("StartSession: %w", err) + } + + if err := tx.Commit(); err != nil { + return Session{}, fmt.Errorf("StartSession: commit: %w", err) + } + + rows, err = validateRows(ctx, s.steps.StepsFor(orgID), rows) + if err != nil { + return Session{}, fmt.Errorf("StartSession: %w", err) + } + + if err := persistRows(ctx, s.repo, rows); err != nil { + return Session{}, fmt.Errorf("StartSession: %w", err) + } + + session, err = s.repo.UpdateSessionStatus(ctx, session.ID, StatusReady) + if err != nil { + return Session{}, fmt.Errorf("StartSession: %w", err) + } + + return session, nil +} + +func (s *Service) stageSession(ctx context.Context, tx *sql.Tx, orgID string, format Format, records []RawRecord) (Session, []Row, error) { + session, err := s.repo.InsertSessionTx(ctx, tx, Session{ + ID: uid.New(), + OrgID: orgID, + Format: format, + Status: StatusDraft, + TargetEntity: s.entity, + }) + if err != nil { + return Session{}, nil, fmt.Errorf("stageSession: %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{}, nil, fmt.Errorf("stageSession: auto-map %q: %w", col, err) + } + } + } + + rows := make([]Row, 0, len(records)) + for _, rec := range records { + blob, err := json.Marshal(rec.Fields) + if err != nil { + return Session{}, nil, fmt.Errorf("stageSession: 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{}, nil, fmt.Errorf("stageSession: insert row %d: %w", rec.RowNumber, err) + } + rows = append(rows, row) + } + + return session, rows, nil +} + +// validateRows runs all steps against rows and assigns ActionCreate to rows +// that passed all steps. +func validateRows(ctx context.Context, steps []Step, rows []Row) ([]Row, error) { + rows, err := runSteps(ctx, rows, steps) + if err != nil { + return nil, fmt.Errorf("ValidateRows: %w", err) + } + for i, row := range rows { + if row.Status == RowStatusNew { + rows[i].Status = RowStatusValid + rows[i].Action = ActionCreate + } + } + return rows, nil +} + +// persistRows writes the status and action of each row back to the database. +func persistRows(ctx context.Context, repo *Repository, rows []Row) error { + for _, row := range rows { + if err := repo.UpdateDataStatus(ctx, row.ID, row.Status, row.ErrorMessage); err != nil { + return fmt.Errorf("PersistRows: row %s: %w", row.ID, err) + } + if _, err := repo.UpdateDataAction(ctx, row.ID, row.Action); err != nil { + return fmt.Errorf("PersistRows: action %s: %w", row.ID, err) + } + } + return nil +} + +// GetStagedSession returns the active staged session for an org, or ErrNotFound if none exists. +func (s *Service) GetStagedSession(ctx context.Context, orgID string) (Session, error) { + session, err := s.repo.GetStagedSession(ctx, orgID) + if err != nil { + return Session{}, fmt.Errorf("get staged session: %w", err) + } + return session, nil +} + +// Delete removes a session and all its rows and mappings (via CASCADE). +func (s *Service) Delete(ctx context.Context, sessionID string) error { + if err := s.repo.DeleteSession(ctx, sessionID); err != nil { + return fmt.Errorf("Delete: %w", err) + } + return nil +} + +// ListData returns all staged rows for a session. +func (s *Service) ListData(ctx context.Context, sessionID string) ([]Row, error) { + rows, err := s.repo.ListData(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("ListData: %w", err) + } + return rows, 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 by delegating target-entity writes to the +// CommitHandler provided at construction. +func (s *Service) Commit(ctx context.Context, sessionID string) error { + session, err := s.repo.GetSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("Commit: %w", err) + } + + 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 := s.handler.Commit(ctx, session.OrgID, 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 +} + +func runSteps(ctx context.Context, rows []Row, steps []Step) ([]Row, error) { + var err error + for _, step := range steps { + rows, err = step(ctx, rows) + if err != nil { + return nil, err + } + } + return rows, nil +} diff --git a/internal/import/template.csv b/internal/import/template.csv new file mode 100644 index 0000000..201cb2b --- /dev/null +++ b/internal/import/template.csv @@ -0,0 +1,5 @@ +Name,Type,Tracking 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,Unit Serial Number,Unit Manufacturer Serial,Unit Purchase Price,Unit Purchased At,Next Inspection At,Unit Active,Unit Remark +Shure SM58,standard,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,standard,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,standard,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,kit,serialized,Rental,Case,Pelican,Main Warehouse,10.00,120.00,Carry-on approved hard case,2.8,56.3,35.5,22.9,,,,,,,PC-1510-001,,120.00,2024-01-10,,true, diff --git a/internal/templates/layouts/equipment.tmpl b/internal/templates/layouts/equipment.tmpl index 222897e..2add139 100644 --- a/internal/templates/layouts/equipment.tmpl +++ b/internal/templates/layouts/equipment.tmpl @@ -2,4 +2,11 @@ {{ template "header" . }}
{{template "page" .}}
{{ template "footer" . }} + {{ with .Flash }} +
+ +
+ {{ end }} {{end}} diff --git a/internal/templates/pages/equipment/index.tmpl b/internal/templates/pages/equipment/index.tmpl index 1052dc7..93b8fab 100644 --- a/internal/templates/pages/equipment/index.tmpl +++ b/internal/templates/pages/equipment/index.tmpl @@ -22,12 +22,21 @@