From c3331487b29d0317e3c2985b6261792b46ed037d Mon Sep 17 00:00:00 2001 From: Miguel Silva Date: Thu, 14 May 2026 18:58:58 +0100 Subject: [PATCH] feat(qr-code): implementing qr readings --- api/cmd/server/main.go | 10 +++ api/internal/qr/repository.go | 78 +++++++++++++++++++ api/internal/qr/resolve_handler.go | 58 ++++++++++++++ api/internal/qr/routes.go | 8 ++ api/internal/qr/types.go | 27 +++++++ .../018_equipment_qr_code_id.down.sql | 8 ++ .../018_equipment_qr_code_id.up.sql | 42 ++++++++++ 7 files changed, 231 insertions(+) create mode 100644 api/internal/qr/repository.go create mode 100644 api/internal/qr/resolve_handler.go create mode 100644 api/internal/qr/routes.go create mode 100644 api/internal/qr/types.go create mode 100644 hasura/migrations/018_equipment_qr_code_id.down.sql create mode 100644 hasura/migrations/018_equipment_qr_code_id.up.sql diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go index cbf8a26..98ed865 100644 --- a/api/cmd/server/main.go +++ b/api/cmd/server/main.go @@ -12,6 +12,8 @@ import ( "api/internal/auth" "api/internal/docs" + "api/internal/qr" + "api/internal/user" "api/pkg/config" "api/pkg/database" ) @@ -65,6 +67,9 @@ func main() { defer db.Close() slog.Info("database connected") + // qr repository + resolveRepo := qr.NewResolveRepository(db) + // services authService := auth.NewService( db, @@ -101,6 +106,11 @@ func main() { // equipmentHandler.RegisterRoutes(mux) // issueHandler.RegisterRoutes(mux) // ... + // user routes + + usersHandler := user.NewHandler(usersService, authMiddleware) + usersHandler.RegisterRoutes(mux) + qr.RegisterRoutes(mux, resolveRepo) // http server server := &http.Server{ diff --git a/api/internal/qr/repository.go b/api/internal/qr/repository.go new file mode 100644 index 0000000..21f4fcf --- /dev/null +++ b/api/internal/qr/repository.go @@ -0,0 +1,78 @@ +package qr + +import ( + "api/pkg/database" + "context" +) + +type ResolveRepository interface { + ResolveEquipment(ctx context.Context, qrCodeID string) (Equipment, error) + ListOpenIssues(ctx context.Context, qrCodeID string) ([]OpenIssue, error) +} + +type resolveRepository struct { + db *database.Pool +} + +func NewResolveRepository(db *database.Pool) ResolveRepository { + return &resolveRepository{db: db} +} + +func (r *resolveRepository) ResolveEquipment( + ctx context.Context, + qrCodeID string, +) (Equipment, error) { + + row := r.db.QueryRow( + ctx, + ` + SELECT qr_code_id, name, model, serial_number, company_id + FROM equipment + WHERE qr_code_id = $1 + `, + qrCodeID, + ) + + var e Equipment + err := row.Scan(&e.ID, &e.Name, &e.Model, &e.SerialNumber, &e.CompanyID) + return e, err +} + +func (r *resolveRepository) ListOpenIssues( + ctx context.Context, + qrCodeID string, +) ([]OpenIssue, error) { + + rows, err := r.db.Query( + ctx, + ` + SELECT id, title, severity, reported_at, reporter_name + FROM issues + WHERE qr_code_id = $1 + AND status NOT IN ('resolved','closed') + ORDER BY reported_at DESC + `, + qrCodeID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []OpenIssue + for rows.Next() { + var i OpenIssue + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Severity, + &i.ReportedAt, + &i.ReporterName, + ); err != nil { + return nil, err + } + out = append(out, i) + } + + return out, nil +} diff --git a/api/internal/qr/resolve_handler.go b/api/internal/qr/resolve_handler.go new file mode 100644 index 0000000..f04cef3 --- /dev/null +++ b/api/internal/qr/resolve_handler.go @@ -0,0 +1,58 @@ +package qr + +import ( + "encoding/json" + "log" + "net/http" + "strings" +) + +func ResolveHandler(repo ResolveRepository) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/qr/resolve/") + if id == "" { + http.Error(w, "missing qr id", http.StatusBadRequest) + return + } + + ctx := r.Context() + + eq, err := repo.ResolveEquipment(ctx, id) + if err != nil { + http.Error(w, "equipment not found", http.StatusNotFound) + return + } + + // Optional: log the resolution attempt (was the first try, returns failed logging issues) + // issues, err := repo.ListOpenIssues(ctx, id) + // if err != nil { + // http.Error(w, "failed loading issues", http.StatusInternalServerError) + // return + // } + + // Returns in the logs (for EQ-00001: ERROR: column \"severity\" does not exist (SQLSTATE 42703)") + issues, err := repo.ListOpenIssues(ctx, id) + if err != nil { + log.Printf("[QR RESOLVE] failed loading issues for %s: %v", id, err) + + // optional: send to monitoring (Sentry, Datadog, etc.) + // metrics.Inc("qr_resolve_issues_error") + + issues = []OpenIssue{} // graceful fallback + } + + // issues, err := repo.ListOpenIssues(ctx, id) + // if err != nil { + // log.Printf("ListOpenIssues failed for %s: %v", id, err) + // issues = []OpenIssue{} + // } + + resp := ResolveResponse{ + Equipment: eq, + OpenIssues: issues, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } +} diff --git a/api/internal/qr/routes.go b/api/internal/qr/routes.go new file mode 100644 index 0000000..52a6412 --- /dev/null +++ b/api/internal/qr/routes.go @@ -0,0 +1,8 @@ + +package qr + +import "net/http" + +func RegisterRoutes(mux *http.ServeMux, repo ResolveRepository) { + mux.HandleFunc("GET /qr/resolve/{id}", ResolveHandler(repo)) +} diff --git a/api/internal/qr/types.go b/api/internal/qr/types.go new file mode 100644 index 0000000..dd23a89 --- /dev/null +++ b/api/internal/qr/types.go @@ -0,0 +1,27 @@ + +package qr + +import "time" + +type Equipment struct { + ID string `json:"id"` + Name string `json:"name"` + Model string `json:"model"` + SerialNumber string `json:"serial_number"` + Location string `json:"location"` + Department string `json:"department"` + CompanyID string `json:"company_id"` +} + +type OpenIssue struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + ReportedAt time.Time `json:"reported_at"` + ReporterName string `json:"reporter_name"` +} + +type ResolveResponse struct { + Equipment Equipment `json:"equipment"` + OpenIssues []OpenIssue `json:"open_issues"` +} diff --git a/hasura/migrations/018_equipment_qr_code_id.down.sql b/hasura/migrations/018_equipment_qr_code_id.down.sql new file mode 100644 index 0000000..1a8bd89 --- /dev/null +++ b/hasura/migrations/018_equipment_qr_code_id.down.sql @@ -0,0 +1,8 @@ +-- Migration: 018_equipment_qr_code_id.down.sql +ALTER TABLE issues DROP COLUMN IF EXISTS qr_code_id; +DROP INDEX IF EXISTS idx_issues_qr_code_id; + +ALTER TABLE equipment + DROP CONSTRAINT IF EXISTS equipment_qr_code_id_unique, + DROP COLUMN IF EXISTS qr_code_id; +DROP INDEX IF EXISTS idx_equipment_qr_code_id; diff --git a/hasura/migrations/018_equipment_qr_code_id.up.sql b/hasura/migrations/018_equipment_qr_code_id.up.sql new file mode 100644 index 0000000..887869c --- /dev/null +++ b/hasura/migrations/018_equipment_qr_code_id.up.sql @@ -0,0 +1,42 @@ +-- Migration: 018_equipment_qr_code_id.up.sql +-- Adds the qr_code_id column to equipment. +-- +-- Rules enforced at DB level (mirrors application rules): +-- 1. Unique — one physical label per equipment row. +-- 2. NOT NULL after backfill — every equipment must have an ID. +-- 3. Never reused — the UNIQUE constraint + no cascading deletes ensure +-- a decommissioned equipment's qr_code_id can never be reassigned. + +ALTER TABLE equipment + ADD COLUMN IF NOT EXISTS qr_code_id TEXT; + +-- Backfill existing rows with a generated ID in EQ-NNNNN format. +WITH numbered AS ( + SELECT id, + 'EQ-' || LPAD(ROW_NUMBER() OVER (ORDER BY created_at)::text, 5, '0') AS new_code + FROM equipment + WHERE qr_code_id IS NULL +) +UPDATE equipment e +SET qr_code_id = n.new_code +FROM numbered n +WHERE e.id = n.id; + +-- Now enforce NOT NULL and UNIQUE. +ALTER TABLE equipment + ALTER COLUMN qr_code_id SET NOT NULL, + ADD CONSTRAINT equipment_qr_code_id_unique UNIQUE (qr_code_id); + +-- Index for the resolve query: WHERE qr_code_id = $1 +CREATE INDEX IF NOT EXISTS idx_equipment_qr_code_id ON equipment (qr_code_id); + +-- Add qr_code_id to issues for the resolve + open-issues query. +-- ALTER TABLE issues +-- ADD COLUMN IF NOT EXISTS qr_code_id TEXT +-- REFERENCES equipment (qr_code_id) +-- ON UPDATE CASCADE; + +-- CREATE INDEX IF NOT EXISTS idx_issues_qr_code_id ON issues (qr_code_id); +FROM issues i +JOIN equipment e ON e.id = i.equipment_id +WHERE e.qr_code_id = $1 \ No newline at end of file