Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (

"api/internal/auth"
"api/internal/docs"
"api/internal/qr"
"api/internal/user"
"api/pkg/config"
"api/pkg/database"
)
Expand Down Expand Up @@ -65,6 +67,9 @@ func main() {
defer db.Close()
slog.Info("database connected")

// qr repository
resolveRepo := qr.NewResolveRepository(db)

// services
authService := auth.NewService(
db,
Expand Down Expand Up @@ -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{
Expand Down
78 changes: 78 additions & 0 deletions api/internal/qr/repository.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions api/internal/qr/resolve_handler.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions api/internal/qr/routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

package qr

import "net/http"

func RegisterRoutes(mux *http.ServeMux, repo ResolveRepository) {
mux.HandleFunc("GET /qr/resolve/{id}", ResolveHandler(repo))
}
27 changes: 27 additions & 0 deletions api/internal/qr/types.go
Original file line number Diff line number Diff line change
@@ -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"`
}
8 changes: 8 additions & 0 deletions hasura/migrations/018_equipment_qr_code_id.down.sql
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 42 additions & 0 deletions hasura/migrations/018_equipment_qr_code_id.up.sql
Original file line number Diff line number Diff line change
@@ -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