diff --git a/.gitignore b/.gitignore index 494d016..3f52a08 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,8 @@ Thumbs.db test-results/ playwright-report/ playwright/.cache/ -verify_ui.py \ No newline at end of file +verify_ui.py + +# Air golang +tmp/ +vendor/ diff --git a/SYSTEM_DESIGN_README.md b/SYSTEM_DESIGN_README.md index 25e988b..029eece 100644 --- a/SYSTEM_DESIGN_README.md +++ b/SYSTEM_DESIGN_README.md @@ -422,7 +422,6 @@ erDiagram int purchase_cost_cents int expected_lifespan_years text status - text qr_code_id text notes } @@ -570,6 +569,317 @@ erDiagram users ||--o{ notifications_log : "receives" ``` +--- + +## Information per table + +## Tenancy anchor — companies + +```mermaid +erDiagram + companies { + uuid id PK + text name + text slug + text industry + text plan + text timezone + jsonb branding + timestamptz created_at + } + + users { + uuid id PK + uuid company_id FK + text name + text email + text phone + text role + uuid default_location_id FK + jsonb notification_prefs + timestamptz last_login_at + } +``` + +Every single table has company_id. This is the outermost security boundary. Hasura enforces it on every query via JWT claims — a misconfigured token literally cannot see another tenant's rows. + +- `slug` deserves attention: it's the URL-safe identifier (opera-fix, tasca-do-porto) used in subdomains or path prefixes. Set it immutable after creation — changing it breaks bookmarked URLs. + +- `branding` as JSONB is intentional. It holds { primaryColor, logoUrl, emailFrom } — things that vary per company but don't need their own table. JSONB is fine here because you never query inside it, you fetch the whole object. + +- `timezone` at the company level is the fallback. Individual locations override it. Business logic never touches Date() directly — it always converts UTC → location timezone at the display layer. + +## Configurable hierarchy — location_types + locations + +```mermaid +erDiagram + location_types { + uuid id PK + uuid company_id FK + text name + text icon + int expected_depth + } + + severity_levels { + uuid id PK + uuid company_id FK + text name + text color + int sla_hours + bool sms_alert + bool bypass_quiet_hours + int sort_order + } + + equipment_categories { + uuid id PK + uuid company_id FK + text name + text icon + int default_pm_interval_days + text industry_hint + } + + issue_categories { + uuid id PK + uuid company_id FK + text name + text icon + uuid default_severity_id FK + } +``` + +This is the most important design decision in the schema. A fixed 4-level hierarchy breaks the moment you add hotels or hospitals. The self-referencing locations table with location_types solves this cleanly. + +- `path` (e.g. tasca-do-porto/kitchen) is a materialised column — computed on insert/update by a trigger or application code, stored as text. It makes subtree queries a single WHERE path LIKE 'tasca-do-porto%' rather than a recursive CTE on every request. The trade-off: you must keep path consistent on renames. This is acceptable because location renames are rare and can be handled with a transaction that updates all descendants. + +- `depth` is a denormalised integer — redundant with the path, but useful for fast "give me all depth-0 nodes" queries without string parsing. + +- `manager_id` is a FK to users. One location has one primary manager. If you need multiple managers per location later, that becomes a join table (location_managers). Don't over-engineer it now. + +### Company-owned config tables + +`severity_levels`, `issue_categories`, `equipment_categories` are all per-company. This is what makes the platform multi-vertical without hardcoding anything. +Key fields worth discussing: + +- `severity_levels.sla_hours` — this drives the SLA deadline calculation on every new issue. sla_deadline = reported_at + INTERVAL 'X hours'. The SLA monitor cron reads this via a join, not from a hardcoded constant. + +- `severity_levels.bypass_quiet_hours` — Critical alerts always go out, regardless of user notification preferences. This flag lives on the severity level, not on the notification, so it's configurable per company. + +- `severity_levels.sms_alert` — SMS costs money. This flag prevents a medium-severity issue from triggering Twilio. Default: only Critical = true. + +- `issue_categories.default_severity_id` — when a staff member selects "Safety Hazard", the severity pre-fills to Critical. This is a UX shortcut that reduces reporting friction. Users can override it. + +- `equipment_categories.default_pm_interval_days` — the default preventive maintenance interval when a PM task is created for this category. A technician adding a PM task for a refrigerator gets 30 days pre-filled; they adjust as needed. + +## The core asset — equipment + +```mermaid +erDiagram + + locations { + uuid id PK + uuid company_id FK + uuid parent_id FK + uuid location_type_id FK + uuid manager_id FK + text name + text path + int depth + text timezone + text address + } + + equipment { + uuid id PK + uuid company_id FK + uuid location_id FK + uuid category_id FK + uuid parent_equipment_id FK + bool is_component + text name + text serial_number + text manufacturer + text model + date install_date + date warranty_expiry + int purchase_cost_cents + int expected_lifespan_years + text status + text notes + } + + equipment_photos { + uuid id PK + uuid equipment_id FK + text storage_url + text caption + bool is_primary + timestamptz uploaded_at + } +``` + +The most critical table. Every analytic, every issue, every PM schedule traces back here. + +- `company_id` is denormalised here (it's already reachable via location → company). This is intentional — it makes the Hasura row-level permission rule a single column check rather than a join. Worth the redundancy. + +- `id`, will be the equipment id. It's unique and never reused, even after decommission. QR labels printed with this ID must never become ambiguous — if equipment is replaced, the old ID stays on the old record, the new unit gets a new ID. + +- `purchase_cost_cents` and all monetary values are INTEGER (cents). Never DECIMAL or FLOAT for money. Floating-point arithmetic on financial values causes silent rounding errors that compound over time. + +- `parent_equipment_id` is nullable. Phase 1: null for everything. Phase 2: populate for high-value components (a compressor inside a specific refrigeration unit). The column costs nothing empty, and adding it later would require a migration that touches every equipment row. + +- `is_component` is a boolean flag that separates "staff would scan this" from "technician references this during repair". When is_component = true, the equipment doesn't get its own QR label and doesn't appear in the employee-facing reporting flow. + +- `status` is an enum: active | under_repair | decommissioned. decommissioned is important — it preserves history without polluting active equipment lists. Never hard-delete equipment. + +- `warranty_expiry` — the Go cron sends an alert 30 days before this date. Without this field, warranty claims get missed and repairs that should be free get paid for. This field pays for itself. + +## Issue lifecycle — issues + +```mermaid +erDiagram + + issues { + uuid id PK + uuid company_id FK + uuid equipment_id FK + uuid location_id FK + uuid category_id FK + uuid severity_id FK + uuid reporter_id FK + uuid assigned_to FK + text status + text title + text description + timestamptz reported_at + timestamptz sla_deadline + timestamptz assigned_at + timestamptz resolved_at + timestamptz closed_at + text resolution_notes + } + + issue_photos { + uuid id PK + uuid issue_id FK + text storage_url + text stage + uuid uploaded_by FK + timestamptz uploaded_at + } + + issue_comments { + uuid id PK + uuid issue_id FK + uuid author_id FK + text body + timestamptz created_at + } +``` + +The most frequently written and read table. Index design is critical here. + +- `location_id` is denormalised (reachable via `equipment` → `location`). Same reason as `company_id` on `equipment` — avoids a join on the hottest query path: "show all open issues for location X". + +- `severity_id` is now a FK to severity_levels, not a hardcoded enum. This means a hospital can have a "30-minute" severity level that a restaurant doesn't. The severity is company-configurable. + +- `sla_deadline` is computed at insert time: `reported_at` + `severity.sla_hours`. It's stored, not calculated on read, because the SLA monitor cron queries it with WHERE `sla_deadline < NOW() AND status NOT IN ('resolved', 'closed')`. A computed column would kill that index. + +The timestamp chain — `reported_at` → `assigned_at` → `resolved_at` → `closed_at` — is what makes MTTR calculation possible. Every transition is recorded, not just the final state. This also enables "time to first assignment" as a separate metric, which reveals whether managers are slow to respond even if technicians are fast. + +- `resolution_notes` lives on the issue, not on maintenance_actions. An issue can be resolved without a full maintenance action (e.g., the employee's report was incorrect — the equipment was fine). The notes here are the manager's closure summary. + +## Repair documentation — maintenance_actions + parts_used + +```mermaid +erDiagram + + maintenance_actions { + uuid id PK + uuid issue_id FK + uuid technician_id FK + text action_description + text root_cause + text component_type + text component_name + int labor_minutes + timestamptz start_time + timestamptz end_time + } + + parts_used { + uuid id PK + uuid maintenance_action_id FK + text part_name + text part_number + int quantity + int unit_cost_cents + text supplier + } +``` + +- `maintenance_actions` is the technician's work log. One issue can have multiple actions (a technician starts, orders parts, comes back to finish). This is why it's a separate table with its own PK, not fields on issues. + +- `component_type` and component_name are the Phase 1 approach to sub-equipment tracking. Instead of a full sub-asset hierarchy, the technician notes "I replaced the compressor" as structured text. This is enough to aggregate WHERE component_name = 'compressor' across all repairs and answer "how many compressors have we replaced this year and at what cost?" — without the UX overhead of a full component tree. + +- `labor_minutes` is derived from end_time - start_time but stored explicitly. Technicians sometimes forget to clock out and manually correct the duration. Storing it separately from the timestamps accommodates that without breaking the audit trail. + +- `parts_used` is the financial goldmine. `part_name` + `part_number` + `unit_cost_cents` + `supplier` per line item gives you: total cost per repair, total cost per equipment over its lifetime, most-replaced parts across all locations, supplier price comparison. All of this falls out of simple aggregations on this table. + +## Preventive maintenance — preventive_tasks + preventive_schedules + +```mermaid +erDiagram + + preventive_tasks { + uuid id PK + uuid company_id FK + uuid equipment_id FK + text title + text description + int frequency_days + text assigned_role + int estimated_minutes + bool is_active + } + + preventive_schedules { + uuid id PK + uuid task_id FK + date due_date + timestamptz completed_at + uuid completed_by FK + text notes + text status + } +``` + +- `preventive_tasks` is the template. `preventive_schedules` is the generated instance. + +- `frequency_days` drives schedule generation. The Go daily cron runs: "for every active task, if no pending schedule exists within the next 30 days, create one with `due_date` = `last_completion` + `frequency_days`." This is idempotent — running it twice doesn't double-create schedules. + +- `assigned_role` is a string (employee | technician), not a FK to users. The task is assigned to a role, not a person. The specific person is determined at completion time. This makes the PM library reusable across companies. + +- `preventive_schedules.status` is pending | completed | overdue. The cron also runs a check: `WHERE due_date < NOW() AND status = 'pending'` → `UPDATE SET status = 'overdue'`. This makes "overdue PM tasks" a trivial query with a covered index. + +## Pre-aggregated analytics — equipment_analytics + +This table is the performance safety valve. MTBF, MTTR, health scores, and cost totals across millions of issue rows would be expensive to compute on every dashboard load. + +The Go nightly job pre-calculates everything per (equipment_id, period_date) and writes it here. Dashboards read from this table exclusively. The issues and maintenance_actions tables are never aggregated in real time. + +- `health_score` is a 0–100 float. The formula: normalise MTBF trend + repair cost ratio + age ratio. The exact weights are configurable per company (stored in companies.branding JSONB or a separate config table in Phase 2). The score on the dashboard is a read of a pre-computed column, not a live calculation. + +### Observability — notifications_log + +Every notification attempt is logged with sent_at, delivered_at, and status. This table exists for two reasons: debugging ("why didn't the manager get the critical SMS?") and compliance (GDPR audit trail of what was communicated to whom and when). + +- `delivered_at` is populated by Postmark/Twilio webhooks. If sent_at is set but delivered_at is null after 30 minutes, the Go service can alert on notification failures. + +--- + ### Critical Indexes ```sql @@ -978,7 +1288,7 @@ gantt - [ ] `location_types`, `severity_levels`, `issue_categories`, `equipment_categories` all populated from template - [ ] Self-referencing locations hierarchy works at depth 2 (Restaurant → Area) - [ ] `path` column correctly materialised on insert and update -- [ ] Equipment created with `qr_code_id` auto-generated +- [ ] Equipment created with `equipment_id` auto-generated **Weeks 5–6 — Core Workflow** diff --git a/api/.air.toml b/api/.air.toml new file mode 100644 index 0000000..ed4bd69 --- /dev/null +++ b/api/.air.toml @@ -0,0 +1,23 @@ +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o tmp/operafix-api ./cmd/server" + bin = "tmp/operafix-api" + include_ext = ["go", "toml", "yaml"] + exclude_dir = ["vendor", "testdata"] + delay = 500 + kill_delay = "200ms" + rerun = false + +[log] + time = true + +[color] + main = "yellow" + watcher = "cyan" + build = "green" + runner = "magenta" + +[misc] + clean_on_exit = true diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..ee4fb02 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,67 @@ +FROM golang:1.26-alpine AS base + +# Install system dependencies: +# git — required by `go mod download` for private modules +# and for some go:generate tools +# curl — used in healthchecks and debugging inside the container +# ca-certificates — TLS root certificates so the binary can make HTTPS +# calls to Postmark, Twilio, R2, etc. +# tzdata — timezone data so time.LoadLocation("Europe/Lisbon") +# works inside the container (scratch has no timezone DB) +RUN apk add --no-cache git curl ca-certificates tzdata + +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod download && go mod verify + +# Used in development mode +# `air` watches the mounted source and rebuilds the binary on every save +FROM base AS development + +RUN go install github.com/air-verse/air@latest + +EXPOSE 8080 + +CMD ["air", "-c", ".air.toml"] + +# compiles the production binary +FROM base AS builder + +COPY . . + +# Build metadata injected via ldflags — set by your CI/CD pipeline. +# Useful for /health or /version endpoints to confirm what is deployed. +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_TIME=unknown + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -trimpath \ + -ldflags="-s -w \ + -X main.Version=${VERSION} \ + -X main.Commit=${COMMIT} \ + -X main.BuildTime=${BUILD_TIME}" \ + -o /bin/operafix-api \ + ./cmd/server + +# The final deployable image +# scratch: an empty base image with literally nothing in it +# Attack surface: zero. If an attacker gets RCE they have no tools to +# work with: no bash, no curl, no wget, no package manager +FROM scratch AS production + +# copy TLS certificates from the builder stage +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /bin/operafix-api /operafix-api + +EXPOSE 8080 + +# 65532 is the conventional "nonroot" UID used by distroless images +# This prevents the process from writing to the filesystem or +# escalating privileges even if a vulnerability is exploited +USER 65532:65532 + +ENTRYPOINT ["/operafix-api"] diff --git a/api/cmd/server/main.go b/api/cmd/server/main.go new file mode 100644 index 0000000..cbf8a26 --- /dev/null +++ b/api/cmd/server/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "api/internal/auth" + "api/internal/docs" + "api/pkg/config" + "api/pkg/database" +) + +// build metadata, this comes from the docker file +// injected from the docker compose +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" +) + +func main() { + // config setup + cfg := config.Load() + + // logger setup + var logHandler slog.Handler + + // we only handle debug logs if in development mode + if cfg.IsDev() { + logHandler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + } else { + logHandler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + } + + logger := slog.New(logHandler) + slog.SetDefault(logger) + + slog.Info("starting api", + "version", Version, + "commit", Commit, + "build_time", BuildTime, + "env", cfg.AppEnv, + ) + + // db + // 10 second timeout for initial connection, fail fast at startup + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := database.Connect(ctx, cfg.DatabaseURL) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer db.Close() + slog.Info("database connected") + + // services + authService := auth.NewService( + db, + cfg.JWTSecret, + cfg.JWTAccessExpiry, + cfg.JWTRefreshExpiry, + ) + + authMiddleware := auth.NewMiddleware( + cfg.JWTSecret, + cfg.JWTAccessExpiry, + cfg.JWTRefreshExpiry, + ) + + // router + mux := http.NewServeMux() + + // health check, that we should use for docker healthcheck + // will return the build metadata something similar to status but for the api + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","version":%q,"commit":%q}`, Version, Commit) + }) + + // auth routes + authHandler := auth.NewHandler(authService, authMiddleware, !cfg.IsDev()) + authHandler.RegisterRoutes(mux) + + // docs routes + docsHandler := docs.NewHandler(!cfg.IsDev()) + docsHandler.RegisterRoutes(mux) + + // TODO: register additional route groups here as they are built: + // equipmentHandler.RegisterRoutes(mux) + // issueHandler.RegisterRoutes(mux) + // ... + + // http server + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: mux, + // NOTE: some timeouts to prevent slow clients maybe not needed + // time to read the full request + ReadTimeout: 5 * time.Second, + // time to read request headers only + ReadHeaderTimeout: 2 * time.Second, + // time to write the full response + WriteTimeout: 10 * time.Second, + // keep-alive connection idle timeout + IdleTimeout: 120 * time.Second, + } + + // shitdown gracefully + // Listen for SIGINT (Ctrl+C) and SIGTERM (docker compose stop / Railway deploy) + // On signal: stop accepting new connections, wait up to 30s for in-flight + // requests to finish, then exit cleanly + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // Start server in a goroutine so the signal listener below doesn't block + go func() { + slog.Info("server listening", "addr", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + // Block until we receive a shutdown signal + sig := <-quit + slog.Info("shutdown signal received", "signal", sig.String()) + + // Give in flight requests 30 seconds to complete + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + slog.Error("forced shutdown", "error", err) + os.Exit(1) + } + slog.Info("server stopped cleanly") +} diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..e269475 --- /dev/null +++ b/api/go.mod @@ -0,0 +1,18 @@ +module api + +go 1.26.2 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + golang.org/x/crypto v0.50.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 0000000..b562ce4 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/internal/auth/handler.go b/api/internal/auth/handler.go new file mode 100644 index 0000000..8be9fc6 --- /dev/null +++ b/api/internal/auth/handler.go @@ -0,0 +1,285 @@ +package auth + +import ( + "encoding/json" + "errors" + "github.com/google/uuid" + "net/http" + "time" +) + +// handler holds the HTTP handlers for all auth endpoints +type Handler struct { + svc *Service + middleware *Middleware + isProd bool +} + +func NewHandler(svc *Service, middleware *Middleware, isProd bool) *Handler { + return &Handler{svc: svc, middleware: middleware, isProd: isProd} +} + +// all auth endpoints +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /auth/login", h.login) + mux.HandleFunc("POST /auth/refresh", h.refresh) + mux.HandleFunc("POST /auth/logout", h.logout) + mux.HandleFunc("POST /auth/magic-link", h.sendMagicLink) + mux.HandleFunc("GET /auth/magic-link", h.verifyMagicLink) + // protected route + mux.Handle("GET /auth/me", h.middleware.Require(http.HandlerFunc(h.me))) +} + +// POST /auth/login +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + User userDTO `json:"user"` +} + +func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Email == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "email and password are required") + return + } + + result, err := h.svc.Login(r.Context(), LoginInput{ + Email: req.Email, + Password: req.Password, + }) + if err != nil { + switch { + case errors.Is(err, ErrUnauthorized), errors.Is(err, ErrNoPassword): + // ErrNoPassword → tell client to use magic-link instead + writeError(w, http.StatusUnauthorized, "invalid credentials") + default: + writeError(w, http.StatusInternalServerError, "authentication failed") + } + return + } + + h.setRefreshCookie(w, result.RefreshToken, result.RefreshExpiresAt) + + writeJSON(w, http.StatusOK, loginResponse{ + AccessToken: result.AccessToken, + TokenType: "Bearer", + ExpiresIn: int(15 * time.Minute / time.Second), + User: toUserDTO(result.User), + }) +} + +// POST /auth/refresh +func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) { + // Read refresh token from HttpOnly cookie — not the request body. + // Cookies are not accessible to JavaScript, preventing XSS token theft. + cookie, err := r.Cookie("refresh_token") + if err != nil { + writeError(w, http.StatusUnauthorized, "missing refresh token") + return + } + + result, err := h.svc.Refresh(r.Context(), cookie.Value) + if err != nil { + if errors.Is(err, ErrUnauthorized) { + // Clear the invalid cookie + h.clearRefreshCookie(w) + writeError(w, http.StatusUnauthorized, "session expired, please log in again") + return + } + writeError(w, http.StatusInternalServerError, "refresh failed") + return + } + + h.setRefreshCookie(w, result.RefreshToken, result.RefreshExpiresAt) + + writeJSON(w, http.StatusOK, loginResponse{ + AccessToken: result.AccessToken, + TokenType: "Bearer", + ExpiresIn: int(15 * time.Minute / time.Second), + User: toUserDTO(result.User), + }) +} + +// POST /auth/logout +func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { + // Extract user from the access token in Authorization header. + // We still want to revoke their refresh tokens even on logout. + token, ok := extractBearerToken(r) + if ok { + if claims, err := h.svc.tokens.verify(token); err == nil { + userID, _ := parseUUID(claims.Subject) + _ = h.svc.Logout(r.Context(), userID) + } + } + + // Always clear the cookie, even if token parsing failed. + h.clearRefreshCookie(w) + + writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) +} + +// GET /auth/me +type meResponse struct { + User userDTO `json:"user"` +} + +func (h *Handler) me(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromContext(r.Context()) + + user, err := h.svc.Me(r.Context(), userID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, http.StatusNotFound, "user not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to fetch user") + return + } + + writeJSON(w, http.StatusOK, meResponse{User: toUserDTO(user)}) +} + +//POST /auth/magic-link + +type magicLinkRequest struct { + Email string `json:"email"` +} + +func (h *Handler) sendMagicLink(w http.ResponseWriter, r *http.Request) { + var req magicLinkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Email == "" { + writeError(w, http.StatusBadRequest, "email is required") + return + } + + // Service returns nil token when email doesn't exist — prevents enumeration. + // We respond with 200 in both cases so attackers can't probe for emails. + _, err := h.svc.SendMagicLink(r.Context(), SendMagicLinkInput{Email: req.Email}) + if err != nil { + // Log internally but return 200 to the client. + // TODO: inject a logger and log err here + writeJSON(w, http.StatusOK, map[string]string{ + "message": "if that email exists, a login link has been sent", + }) + return + } + + // TODO: send email via Postmark with the magic link URL: + // https://app.operafix.com/auth/verify?token= + // The email service will be wired in the notify package. + + writeJSON(w, http.StatusOK, map[string]string{ + "message": "if that email exists, a login link has been sent", + }) +} + +// GET /auth/magic-link?token= +func (h *Handler) verifyMagicLink(w http.ResponseWriter, r *http.Request) { + rawToken := r.URL.Query().Get("token") + if rawToken == "" { + writeError(w, http.StatusBadRequest, "missing token") + return + } + + result, err := h.svc.VerifyMagicLink(r.Context(), rawToken) + if err != nil { + if errors.Is(err, ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid or expired link") + return + } + writeError(w, http.StatusInternalServerError, "verification failed") + return + } + + h.setRefreshCookie(w, result.RefreshToken, result.RefreshExpiresAt) + + writeJSON(w, http.StatusOK, loginResponse{ + AccessToken: result.AccessToken, + TokenType: "Bearer", + ExpiresIn: int(15 * time.Minute / time.Second), + User: toUserDTO(result.User), + }) +} + +// Cookie helpers + +// setRefreshCookie writes the refresh token as an HttpOnly, Secure, SameSite=Lax cookie. +// HttpOnly: not accessible to JavaScript — prevents XSS token theft. +// Secure: only sent over HTTPS — set to false in dev (no TLS locally). +// SameSite: Lax allows the cookie to be sent on top-level navigations +// +// (clicking a link) but blocks cross-site POST requests (CSRF protection). +func (h *Handler) setRefreshCookie(w http.ResponseWriter, token string, expiresAt time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: token, + Expires: expiresAt, + HttpOnly: true, + Secure: h.isProd, // false in dev — no HTTPS locally + SameSite: http.SameSiteLaxMode, + Path: "/auth", // scoped to /auth — not sent on every request + }) +} + +// clearRefreshCookie expires the refresh token cookie immediately. +func (h *Handler) clearRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: "", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: h.isProd, + SameSite: http.SameSiteLaxMode, + Path: "/auth", + }) +} + +// DTOs + +// userDTO is the public-facing user representation. +// Never expose password_hash — not even its existence. +type userDTO struct { + ID string `json:"id"` + CompanyID string `json:"company_id"` + Email string `json:"email"` + Role string `json:"role"` +} + +func toUserDTO(u *User) userDTO { + return userDTO{ + ID: u.ID.String(), + CompanyID: u.CompanyID.String(), + Email: u.Email, + Role: string(u.Role), + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func parseUUID(s string) (uuid.UUID, error) { + return uuid.Parse(s) +} diff --git a/api/internal/auth/middleware.go b/api/internal/auth/middleware.go new file mode 100644 index 0000000..d0b7b62 --- /dev/null +++ b/api/internal/auth/middleware.go @@ -0,0 +1,136 @@ +package auth + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +// contextKey is an unexported type for context keys in this package. +// Prevents collisions with context keys from other packages. +type contextKey string + +const ( + contextKeyUserID contextKey = "user_id" + contextKeyCompanyID contextKey = "company_id" + contextKeyRole contextKey = "role" +) + +// Middleware validates the JWT from the Authorization header and injects +// the user's identity into the request context. +// +// On success: calls next handler with user identity in context. +// On failure: writes 401 and stops the chain. +// +// Usage: +// +// mux.Handle("GET /auth/me", authMiddleware.Require(meHandler)) +type Middleware struct { + tokens *tokenService +} + +// NewMiddleware constructs the auth middleware. +func NewMiddleware(jwtSecret string, accessExpiry, refreshExpiry time.Duration) *Middleware { + return &Middleware{ + tokens: newTokenService(jwtSecret, accessExpiry, refreshExpiry), + } +} + +// Require is an http.Handler wrapper that enforces authentication. +// Handlers wrapped with Require can safely call UserIDFromContext — +// the user is guaranteed to be authenticated at that point. +func (m *Middleware) Require(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := extractBearerToken(r) + if !ok { + writeUnauthorized(w, "missing or malformed Authorization header") + return + } + + claims, err := m.tokens.verify(token) + if err != nil { + writeUnauthorized(w, "invalid or expired token") + return + } + + userID, err := uuid.Parse(claims.Subject) + if err != nil { + writeUnauthorized(w, "invalid token subject") + return + } + + companyID, err := uuid.Parse(claims.HasuraClaims.CompanyID) + if err != nil { + writeUnauthorized(w, "invalid company id in token") + return + } + + // Inject identity into context for downstream handlers. + ctx := r.Context() + ctx = context.WithValue(ctx, contextKeyUserID, userID) + ctx = context.WithValue(ctx, contextKeyCompanyID, companyID) + ctx = context.WithValue(ctx, contextKeyRole, Role(claims.HasuraClaims.DefaultRole)) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// ─── Context accessors ──────────────────────────────────────────── +// Call these inside handlers that are wrapped with Require(). +// They panic if called without Require() — intentional, surfaces bugs early. + +// UserIDFromContext returns the authenticated user's UUID from context. +func UserIDFromContext(ctx context.Context) uuid.UUID { + id, ok := ctx.Value(contextKeyUserID).(uuid.UUID) + if !ok { + panic("auth: UserIDFromContext called without Require middleware") + } + return id +} + +// CompanyIDFromContext returns the authenticated user's company UUID from context. +func CompanyIDFromContext(ctx context.Context) uuid.UUID { + id, ok := ctx.Value(contextKeyCompanyID).(uuid.UUID) + if !ok { + panic("auth: CompanyIDFromContext called without Require middleware") + } + return id +} + +// RoleFromContext returns the authenticated user's role from context. +func RoleFromContext(ctx context.Context) Role { + role, ok := ctx.Value(contextKeyRole).(Role) + if !ok { + panic("auth: RoleFromContext called without Require middleware") + } + return role +} + +// ─── helpers ────────────────────────────────────────────────────── + +// extractBearerToken parses the Authorization header. +// Expects: "Authorization: Bearer " +func extractBearerToken(r *http.Request) (string, bool) { + header := r.Header.Get("Authorization") + if header == "" { + return "", false + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "", false + } + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", false + } + return token, true +} + +func writeUnauthorized(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"` + msg + `"}`)) +} diff --git a/api/internal/auth/repository.go b/api/internal/auth/repository.go new file mode 100644 index 0000000..b354d69 --- /dev/null +++ b/api/internal/auth/repository.go @@ -0,0 +1,332 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Role is the set of valid user roles. Must match the CHECK constraint +// in migration 007_users.up.sql and the Hasura permission rule names. +type Role string + +const ( + RoleEmployee Role = "employee" + RoleTechnician Role = "technician" + RoleLocationManager Role = "location_manager" + RoleOpsManager Role = "ops_manager" + RoleAdmin Role = "admin" + RoleSuperAdmin Role = "super_admin" +) + +// User is the minimal projection of the users table needed for auth. +// We only select what authentication requires — not the full row. +type User struct { + ID uuid.UUID + CompanyID uuid.UUID + Email string + Role Role + PasswordHash *string // nullable — magic-link users have no password + DefaultLocationID *uuid.UUID +} + +// MagicLinkToken represents a pending magic-link login request. +type MagicLinkToken struct { + ID uuid.UUID + UserID uuid.UUID + TokenHash string + ExpiresAt time.Time + UsedAt *time.Time +} + +// RefreshToken represents a stored refresh token record. +// We store the hash, never the raw token. +type RefreshToken struct { + ID uuid.UUID + UserID uuid.UUID + TokenHash string + ExpiresAt time.Time + RevokedAt *time.Time +} + +// repository handles all database operations for the auth package. +type repository struct { + db *pgxpool.Pool +} + +func newRepository(db *pgxpool.Pool) *repository { + return &repository{db: db} +} + +// ─── User queries ───────────────────────────────────────────────── + +// getUserByEmail fetches a user by email address. +// Returns ErrNotFound if the user does not exist. +func (r *repository) getUserByEmail(ctx context.Context, email string) (*User, error) { + const q = ` + SELECT id, company_id, email, role, password_hash, default_location_id + FROM users + WHERE email = $1 + LIMIT 1` + + var u User + err := r.db.QueryRow(ctx, q, email).Scan( + &u.ID, + &u.CompanyID, + &u.Email, + &u.Role, + &u.PasswordHash, + &u.DefaultLocationID, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + return &u, nil +} + +// getUserByID fetches a user by primary key. +func (r *repository) getUserByID(ctx context.Context, id uuid.UUID) (*User, error) { + const q = ` + SELECT id, company_id, email, role, password_hash, default_location_id + FROM users + WHERE id = $1 + LIMIT 1` + + var u User + err := r.db.QueryRow(ctx, q, id).Scan( + &u.ID, + &u.CompanyID, + &u.Email, + &u.Role, + &u.PasswordHash, + &u.DefaultLocationID, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by id: %w", err) + } + return &u, nil +} + +// updateLastLogin records the current timestamp as the user's last login. +// Called after every successful authentication. +func (r *repository) updateLastLogin(ctx context.Context, userID uuid.UUID) error { + const q = `UPDATE users SET last_login_at = NOW() WHERE id = $1` + _, err := r.db.Exec(ctx, q, userID) + if err != nil { + return fmt.Errorf("update last login: %w", err) + } + return nil +} + +// getLocationIDsForUser returns the list of location IDs accessible to a user. +// - For employees and location managers: their default location only (for now). +// Phase 2 adds a user_location_assignments join table for multi-location access. +// - For technicians: their assigned locations (same logic for now). +// - For ops_manager and admin: empty slice (Hasura applies no location filter). +func (r *repository) getLocationIDsForUser(ctx context.Context, user *User) ([]string, error) { + switch user.Role { + case RoleOpsManager, RoleAdmin, RoleSuperAdmin: + // These roles see all locations — no location filter in JWT claims. + return nil, nil + } + + if user.DefaultLocationID == nil { + return []string{}, nil + } + + // Phase 1: single default location. + // Phase 2: query user_location_assignments for multi-location list. + return []string{user.DefaultLocationID.String()}, nil +} + +// ─── Refresh token queries ───────────────────────────────────────── + +// createRefreshToken inserts a new refresh token record. +// Only the SHA-256 hash of the token is stored — never the raw value. +func (r *repository) createRefreshToken( + ctx context.Context, + userID uuid.UUID, + tokenHash string, + expiresAt time.Time, +) error { + const q = ` + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) + VALUES (gen_random_uuid(), $1, $2, $3)` + + _, err := r.db.Exec(ctx, q, userID, tokenHash, expiresAt) + if err != nil { + return fmt.Errorf("create refresh token: %w", err) + } + return nil +} + +// getRefreshToken fetches a refresh token by its hash. +// Returns ErrNotFound if absent, ErrTokenExpired if past expiry, +// ErrTokenRevoked if already used. +func (r *repository) getRefreshToken(ctx context.Context, tokenHash string) (*RefreshToken, error) { + const q = ` + SELECT id, user_id, token_hash, expires_at, revoked_at + FROM refresh_tokens + WHERE token_hash = $1 + LIMIT 1` + + var rt RefreshToken + err := r.db.QueryRow(ctx, q, tokenHash).Scan( + &rt.ID, + &rt.UserID, + &rt.TokenHash, + &rt.ExpiresAt, + &rt.RevokedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get refresh token: %w", err) + } + + if rt.RevokedAt != nil { + return nil, ErrTokenRevoked + } + if time.Now().UTC().After(rt.ExpiresAt) { + return nil, ErrTokenExpired + } + + return &rt, nil +} + +// rotateRefreshToken revokes the old token and creates a new one atomically. +// Rotation on every use means a stolen token can only be used once — +// the next use by the legitimate user revokes it and reveals the theft. +func (r *repository) rotateRefreshToken( + ctx context.Context, + oldTokenHash string, + userID uuid.UUID, + newTokenHash string, + expiresAt time.Time, +) error { + tx, err := r.db.Begin(ctx) + if err != nil { + return fmt.Errorf("rotate refresh token: begin tx: %w", err) + } + defer tx.Rollback(ctx) + + // Revoke the old token + const revoke = ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE token_hash = $1` + if _, err := tx.Exec(ctx, revoke, oldTokenHash); err != nil { + return fmt.Errorf("rotate refresh token: revoke old: %w", err) + } + + // Insert the new token + const insert = ` + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) + VALUES (gen_random_uuid(), $1, $2, $3)` + if _, err := tx.Exec(ctx, insert, userID, newTokenHash, expiresAt); err != nil { + return fmt.Errorf("rotate refresh token: insert new: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("rotate refresh token: commit: %w", err) + } + return nil +} + +// revokeAllRefreshTokens invalidates every active refresh token for a user. +// Called on logout to ensure the session is fully terminated. +func (r *repository) revokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error { + const q = ` + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL` + _, err := r.db.Exec(ctx, q, userID) + if err != nil { + return fmt.Errorf("revoke all refresh tokens: %w", err) + } + return nil +} + +// ─── Magic link queries ──────────────────────────────────────────── + +// createMagicLinkToken stores a magic-link token hash with a 15-minute expiry. +func (r *repository) createMagicLinkToken( + ctx context.Context, + userID uuid.UUID, + tokenHash string, +) error { + const q = ` + INSERT INTO magic_link_tokens (id, user_id, token_hash, expires_at) + VALUES (gen_random_uuid(), $1, $2, NOW() + INTERVAL '15 minutes')` + + _, err := r.db.Exec(ctx, q, userID, tokenHash) + if err != nil { + return fmt.Errorf("create magic link token: %w", err) + } + return nil +} + +// consumeMagicLinkToken validates and marks a magic-link token as used. +// Returns the associated user ID if valid. +// A token can only be used once — subsequent attempts return ErrTokenRevoked. +func (r *repository) consumeMagicLinkToken( + ctx context.Context, + tokenHash string, +) (uuid.UUID, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return uuid.Nil, fmt.Errorf("consume magic link: begin tx: %w", err) + } + defer tx.Rollback(ctx) + + const q = ` + SELECT id, user_id, expires_at, used_at + FROM magic_link_tokens + WHERE token_hash = $1 + LIMIT 1` + + var token MagicLinkToken + err = tx.QueryRow(ctx, q, tokenHash).Scan( + &token.ID, + &token.UserID, + &token.ExpiresAt, + &token.UsedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, ErrNotFound + } + if err != nil { + return uuid.Nil, fmt.Errorf("consume magic link: fetch: %w", err) + } + + if token.UsedAt != nil { + return uuid.Nil, ErrTokenRevoked + } + if time.Now().UTC().After(token.ExpiresAt) { + return uuid.Nil, ErrTokenExpired + } + + // Mark as used + const mark = `UPDATE magic_link_tokens SET used_at = NOW() WHERE id = $1` + if _, err := tx.Exec(ctx, mark, token.ID); err != nil { + return uuid.Nil, fmt.Errorf("consume magic link: mark used: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, fmt.Errorf("consume magic link: commit: %w", err) + } + + return token.UserID, nil +} diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go new file mode 100644 index 0000000..cb321a1 --- /dev/null +++ b/api/internal/auth/service.go @@ -0,0 +1,233 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +// the correct HTTP status code without leaking internal details +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") + ErrTokenExpired = errors.New("token expired") + ErrTokenRevoked = errors.New("token revoked") + ErrNoPassword = errors.New("user has no password set — use magic link") +) + +// service is the auth business logic layer +// handlers call Service methods; Service orchestrates repo + token operations +type Service struct { + repo *repository + tokens *tokenService +} + +// NewService constructs the auth service with all dependencies wired. +func NewService(db *pgxpool.Pool, jwtSecret string, accessExpiry, refreshExpiry time.Duration) *Service { + return &Service{ + repo: newRepository(db), + tokens: newTokenService(jwtSecret, accessExpiry, refreshExpiry), + } +} + +// Login +// LoginInput is the request payload for password-based login. +type LoginInput struct { + Email string + Password string +} + +// LoginResult is returned on successful authentication. +type LoginResult struct { + AccessToken string + RefreshToken string + RefreshExpiresAt time.Time + User *User +} + +// Login authenticates a user with email + password. +// Returns a token pair on success. +func (s *Service) Login(ctx context.Context, input LoginInput) (*LoginResult, error) { + user, err := s.repo.getUserByEmail(ctx, input.Email) + if err != nil { + if errors.Is(err, ErrNotFound) { + // Return ErrUnauthorized — never reveal whether the email exists. + return nil, ErrUnauthorized + } + return nil, fmt.Errorf("login: %w", err) + } + + // User was created via magic-link and has never set a password. + if user.PasswordHash == nil { + return nil, ErrNoPassword + } + + // bcrypt.CompareHashAndPassword does constant-time comparison. + // Returns non-nil error if the password doesn't match. + if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(input.Password)); err != nil { + return nil, ErrUnauthorized + } + + return s.issueSession(ctx, user) +} + +// Refresh +// Refresh validates an existing refresh token and issues a new token pair. +// The old refresh token is revoked — rotation on every use. +func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*LoginResult, error) { + tokenHash := hashToken(rawRefreshToken) + + rt, err := s.repo.getRefreshToken(ctx, tokenHash) + if err != nil { + // Map token-specific errors to ErrUnauthorized for the handler. + if errors.Is(err, ErrNotFound) || errors.Is(err, ErrTokenRevoked) || errors.Is(err, ErrTokenExpired) { + return nil, ErrUnauthorized + } + return nil, fmt.Errorf("refresh: get token: %w", err) + } + + user, err := s.repo.getUserByID(ctx, rt.UserID) + if err != nil { + return nil, fmt.Errorf("refresh: get user: %w", err) + } + + locationIDs, err := s.repo.getLocationIDsForUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("refresh: get locations: %w", err) + } + + pair, err := s.tokens.issueTokenPair(user, locationIDs) + if err != nil { + return nil, fmt.Errorf("refresh: issue tokens: %w", err) + } + + // Rotate: revoke old token, store new token hash atomically. + if err := s.repo.rotateRefreshToken( + ctx, + tokenHash, + user.ID, + pair.RefreshTokenHash, + pair.RefreshExpiresAt, + ); err != nil { + return nil, fmt.Errorf("refresh: rotate token: %w", err) + } + + _ = s.repo.updateLastLogin(ctx, user.ID) // best-effort, non-fatal + + return &LoginResult{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + RefreshExpiresAt: pair.RefreshExpiresAt, + User: user, + }, nil +} + +// Logout +// Logout revokes all active refresh tokens for the user. +// The access token remains valid until it expires (15 minutes max). +// This is the correct trade-off — revoking JWTs requires a blocklist +// (Redis or DB lookup on every request), which adds latency. +// For this scale, 15-minute max exposure on logout is acceptable. +func (s *Service) Logout(ctx context.Context, userID uuid.UUID) error { + if err := s.repo.revokeAllRefreshTokens(ctx, userID); err != nil { + return fmt.Errorf("logout: %w", err) + } + return nil +} + +// Me +// Me returns the currently authenticated user's profile. +// userID is extracted from the validated JWT by the auth middleware. +func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*User, error) { + user, err := s.repo.getUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("me: %w", err) + } + return user, nil +} + +// Magic link +// SendMagicLinkInput is the request payload for magic-link generation. +type SendMagicLinkInput struct { + Email string +} + +// SendMagicLink generates a magic-link token and returns it. +// The caller (handler) is responsible for emailing the link. +// Returns nil error even when the email doesn't exist — prevents email enumeration. +func (s *Service) SendMagicLink(ctx context.Context, input SendMagicLinkInput) (token string, err error) { + user, err := s.repo.getUserByEmail(ctx, input.Email) + if err != nil { + if errors.Is(err, ErrNotFound) { + // Do not reveal whether the email is registered. + // Return a dummy token to maintain consistent response time. + return "", nil + } + return "", fmt.Errorf("send magic link: %w", err) + } + + rawToken, err := generateSecureToken(32) + if err != nil { + return "", fmt.Errorf("send magic link: generate token: %w", err) + } + + if err := s.repo.createMagicLinkToken(ctx, user.ID, hashToken(rawToken)); err != nil { + return "", fmt.Errorf("send magic link: store token: %w", err) + } + + return rawToken, nil +} + +// VerifyMagicLink validates a magic-link token and issues a full session. +// Called when the user clicks the link in their email. +func (s *Service) VerifyMagicLink(ctx context.Context, rawToken string) (*LoginResult, error) { + tokenHash := hashToken(rawToken) + + userID, err := s.repo.consumeMagicLinkToken(ctx, tokenHash) + if err != nil { + if errors.Is(err, ErrNotFound) || errors.Is(err, ErrTokenRevoked) || errors.Is(err, ErrTokenExpired) { + return nil, ErrUnauthorized + } + return nil, fmt.Errorf("verify magic link: %w", err) + } + + user, err := s.repo.getUserByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("verify magic link: get user: %w", err) + } + + return s.issueSession(ctx, user) +} + +// internal helpers +// issueSession is the shared final step of any successful authentication: +// fetch location IDs → issue token pair → store refresh token → update last login +func (s *Service) issueSession(ctx context.Context, user *User) (*LoginResult, error) { + locationIDs, err := s.repo.getLocationIDsForUser(ctx, user) + if err != nil { + return nil, fmt.Errorf("issue session: get locations: %w", err) + } + + pair, err := s.tokens.issueTokenPair(user, locationIDs) + if err != nil { + return nil, fmt.Errorf("issue session: issue tokens: %w", err) + } + + if err := s.repo.createRefreshToken(ctx, user.ID, pair.RefreshTokenHash, pair.RefreshExpiresAt); err != nil { + return nil, fmt.Errorf("issue session: store refresh token: %w", err) + } + + _ = s.repo.updateLastLogin(ctx, user.ID) // best-effort + + return &LoginResult{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + RefreshExpiresAt: pair.RefreshExpiresAt, + User: user, + }, nil +} diff --git a/api/internal/auth/token.go b/api/internal/auth/token.go new file mode 100644 index 0000000..65e06a6 --- /dev/null +++ b/api/internal/auth/token.go @@ -0,0 +1,214 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// HasuraClaims is the custom namespace Hasura reads from the JWT. +// Every field maps directly to a Hasura session variable used in +// row-level permission rules across all tables. +// +// Hasura permission example for issues table: +// +// { "company_id": { "_eq": "X-Hasura-Company-Id" } } +type HasuraClaims struct { + AllowedRoles []string `json:"x-hasura-allowed-roles"` + DefaultRole string `json:"x-hasura-default-role"` + UserID string `json:"x-hasura-user-id"` + CompanyID string `json:"x-hasura-company-id"` + + // LocationIDs is a Postgres array literal: "{loc-01,loc-02}" + // Hasura reads this for location-scoped roles (location_manager, technician) + // who can only see data from their assigned locations. + // For ops_manager and admin this is omitted — they see all locations. + LocationIDs string `json:"x-hasura-location-ids"` +} + +// Claims is the full JWT payload — standard registered claims plus +// the Hasura namespace required for row-level security enforcement. +type Claims struct { + jwt.RegisteredClaims + + // Hasura reads this exact namespace key from the JWT. + // The key must be "https://hasura.io/jwt/claims" — not configurable. + HasuraClaims HasuraClaims `json:"https://hasura.io/jwt/claims"` +} + +// TokenPair is the result of a successful authentication. +// AccessToken is short-lived and sent in the Authorization header. +// RefreshToken is long-lived and stored in an HttpOnly cookie. +type TokenPair struct { + AccessToken string + RefreshToken string + + // RefreshTokenHash is stored in the database — never the raw token. + // On refresh we hash the incoming token and compare to this value. + RefreshTokenHash string + + AccessExpiresAt time.Time + RefreshExpiresAt time.Time +} + +// tokenService handles JWT signing and verification. +type tokenService struct { + secret []byte + accessExpiry time.Duration + refreshExpiry time.Duration +} + +func newTokenService(secret string, accessExpiry, refreshExpiry time.Duration) *tokenService { + return &tokenService{ + secret: []byte(secret), + accessExpiry: accessExpiry, + refreshExpiry: refreshExpiry, + } +} + +// issueTokenPair creates a new access + refresh token pair for the given user. +// Called on login and on magic-link verification. +func (ts *tokenService) issueTokenPair(user *User, locationIDs []string) (*TokenPair, error) { + now := time.Now().UTC() + accessExp := now.Add(ts.accessExpiry) + refreshExp := now.Add(ts.refreshExpiry) + + // Build Hasura claims from the user's role and location assignments + hasuraClaims := ts.buildHasuraClaims(user, locationIDs) + + // Sign the access token + accessToken, err := ts.signAccessToken(user.ID, hasuraClaims, now, accessExp) + if err != nil { + return nil, fmt.Errorf("issue token pair: sign access token: %w", err) + } + + // Generate a cryptographically random refresh token + rawRefresh, err := generateSecureToken(32) + if err != nil { + return nil, fmt.Errorf("issue token pair: generate refresh token: %w", err) + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: rawRefresh, + RefreshTokenHash: hashToken(rawRefresh), + AccessExpiresAt: accessExp, + RefreshExpiresAt: refreshExp, + }, nil +} + +// signAccessToken builds and signs the JWT with HS256. +func (ts *tokenService) signAccessToken( + userID uuid.UUID, + hasuraClaims HasuraClaims, + now, exp time.Time, +) (string, error) { + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID.String(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(exp), + Issuer: "operafix-api", + }, + HasuraClaims: hasuraClaims, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(ts.secret) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + return signed, nil +} + +// verify parses and validates a JWT string, returning the claims if valid. +func (ts *tokenService) verify(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(t *jwt.Token) (any, error) { + // Reject tokens signed with any algorithm other than HS256. + // Prevents the "algorithm confusion" attack where an attacker + // switches to RS256 and tricks the server into accepting a + // token signed with the public key as the HMAC secret. + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return ts.secret, nil + }, + ) + if err != nil { + return nil, fmt.Errorf("verify token: %w", err) + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("verify token: invalid claims") + } + + return claims, nil +} + +// buildHasuraClaims constructs the Hasura permission claims based on role. +// The claims determine what data the user can read and write via GraphQL. +func (ts *tokenService) buildHasuraClaims(user *User, locationIDs []string) HasuraClaims { + claims := HasuraClaims{ + AllowedRoles: []string{string(user.Role)}, + DefaultRole: string(user.Role), + UserID: user.ID.String(), + CompanyID: user.CompanyID.String(), + } + + // Location-scoped roles get an explicit list of accessible location IDs. + // ops_manager and admin see all locations — no location filter applied. + switch user.Role { + case RoleLocationManager, RoleTechnician, RoleEmployee: + if len(locationIDs) > 0 { + claims.LocationIDs = toPGArray(locationIDs) + } + } + + return claims +} + +// ─── helpers ────────────────────────────────────────────────────── + +// generateSecureToken returns a cryptographically random hex string +// of the given byte length (output is 2× bytes in hex chars). +func generateSecureToken(bytes int) (string, error) { + b := make([]byte, bytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate secure token: %w", err) + } + return hex.EncodeToString(b), nil +} + +// hashToken SHA-256 hashes a token for safe storage in the database. +// We never store raw refresh tokens — only their hashes. +// On verification: hash the incoming token and compare to the stored hash. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +// toPGArray converts a Go string slice to a Postgres array literal. +// e.g. ["a","b"] → "{a,b}" +// This format is what Hasura expects for x-hasura-location-ids. +func toPGArray(ids []string) string { + if len(ids) == 0 { + return "{}" + } + result := "{" + for i, id := range ids { + if i > 0 { + result += "," + } + result += id + } + return result + "}" +} diff --git a/api/internal/docs/handler.go b/api/internal/docs/handler.go new file mode 100644 index 0000000..1705c55 --- /dev/null +++ b/api/internal/docs/handler.go @@ -0,0 +1,380 @@ +package docs + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type Handler struct { + isProd bool +} + +func NewHandler(isProd bool) *Handler { + return &Handler{isProd: isProd} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // TODO: In production we should disable or password-protect these + mux.HandleFunc("GET /docs", h.scalar) + mux.HandleFunc("GET /docs/openapi.json", h.spec) +} + +func (h *Handler) scalar(w http.ResponseWriter, r *http.Request) { + spec, err := json.Marshal(openAPISpec()) + if err != nil { + http.Error(w, "failed to generate spec", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` + + + Operafix API + + + + + + + +`, string(spec)) +} + +// spec serves the raw OpenAPI JSON for external tools (Postman, Insomnia, etc.) +func (h *Handler) spec(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(openAPISpec()) +} + +func openAPISpec() map[string]any { + return map[string]any{ + "openapi": "3.1.0", + "info": map[string]any{ + "title": "Operafix API", + "description": "Multi-tenant facilities management platform — Go API service.", + "version": "0.1.0", + "contact": map[string]any{ + "name": "Operafix", + }, + }, + "servers": []map[string]any{ + { + "url": "http://localhost:8081", + "description": "Local development", + }, + { + "url": "https://app.operafix.com/api", + "description": "Production", + }, + }, + "tags": []map[string]any{ + {"name": "health", "description": "Service health"}, + {"name": "auth", "description": "Authentication — login, logout, token refresh, magic-link"}, + }, + "paths": map[string]any{ + "/health": map[string]any{ + "get": map[string]any{ + "tags": []string{"health"}, + "summary": "Health check", + "description": "Returns service status and build metadata.", + "operationId": "getHealth", + "responses": map[string]any{ + "200": map[string]any{ + "description": "Service is healthy", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("HealthResponse"), + }, + }, + }, + }, + }, + }, + "/auth/login": map[string]any{ + "post": map[string]any{ + "tags": []string{"auth"}, + "summary": "Login with email and password", + "description": "Authenticates a user and returns a short-lived access token. A long-lived refresh token is set as an HttpOnly cookie.", + "operationId": "login", + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("LoginRequest"), + "example": map[string]any{ + "email": "admin@test.com", + "password": "password123", + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Login successful", + "headers": refreshCookieHeader(), + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("AuthResponse"), + }, + }, + }, + "400": errorResponse("Email and password are required"), + "401": errorResponse("Invalid credentials"), + "500": errorResponse("Authentication failed"), + }, + }, + }, + "/auth/refresh": map[string]any{ + "post": map[string]any{ + "tags": []string{"auth"}, + "summary": "Refresh access token", + "description": "Issues a new access token using the refresh token cookie. The old refresh token is revoked and a new one is set.", + "operationId": "refreshToken", + "parameters": []map[string]any{ + { + "in": "cookie", + "name": "refresh_token", + "required": true, + "description": "Refresh token set by /auth/login or previous /auth/refresh", + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Token refreshed", + "headers": refreshCookieHeader(), + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("AuthResponse"), + }, + }, + }, + "401": errorResponse("Missing or expired refresh token"), + "500": errorResponse("Refresh failed"), + }, + }, + }, + "/auth/logout": map[string]any{ + "post": map[string]any{ + "tags": []string{"auth"}, + "summary": "Logout", + "description": "Revokes all active refresh tokens and clears the refresh token cookie.", + "operationId": "logout", + "security": []map[string]any{{"bearerAuth": []string{}}}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Logged out successfully", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{"type": "string", "example": "logged out"}, + }, + }, + }, + }, + }, + }, + }, + }, + "/auth/me": map[string]any{ + "get": map[string]any{ + "tags": []string{"auth"}, + "summary": "Get current user", + "description": "Returns the authenticated user's profile.", + "operationId": "getMe", + "security": []map[string]any{{"bearerAuth": []string{}}}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Current user profile", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "user": ref("User"), + }, + }, + }, + }, + }, + "401": errorResponse("Missing or invalid token"), + "404": errorResponse("User not found"), + }, + }, + }, + "/auth/magic-link": map[string]any{ + "post": map[string]any{ + "tags": []string{"auth"}, + "summary": "Request a magic login link", + "description": "Sends a one-time login link to the provided email. Always returns 200 to prevent email enumeration.", + "operationId": "sendMagicLink", + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "required": []string{"email"}, + "properties": map[string]any{ + "email": map[string]any{ + "type": "string", + "format": "email", + "example": "staff@restaurant.com", + }, + }, + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Request received", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{ + "type": "string", + "example": "if that email exists, a login link has been sent", + }, + }, + }, + }, + }, + }, + "400": errorResponse("Email is required"), + }, + }, + "get": map[string]any{ + "tags": []string{"auth"}, + "summary": "Verify a magic login link", + "description": "Validates the token from a magic-link email and issues a full session. Tokens expire after 15 minutes and can only be used once.", + "operationId": "verifyMagicLink", + "parameters": []map[string]any{ + { + "in": "query", + "name": "token", + "required": true, + "description": "The raw token from the magic-link URL", + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Magic link verified — session issued", + "headers": refreshCookieHeader(), + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("AuthResponse"), + }, + }, + }, + "400": errorResponse("Missing token"), + "401": errorResponse("Invalid or expired link"), + }, + }, + }, + }, + "components": map[string]any{ + "securitySchemes": map[string]any{ + "bearerAuth": map[string]any{ + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT access token — obtained from /auth/login or /auth/refresh. Expires in 15 minutes.", + }, + }, + "schemas": map[string]any{ + "HealthResponse": map[string]any{ + "type": "object", + "required": []string{"status"}, + "properties": map[string]any{ + "status": map[string]any{"type": "string", "example": "ok"}, + "version": map[string]any{"type": "string", "example": "1.0.0"}, + "commit": map[string]any{"type": "string", "example": "a1b2c3d"}, + }, + }, + "LoginRequest": map[string]any{ + "type": "object", + "required": []string{"email", "password"}, + "properties": map[string]any{ + "email": map[string]any{"type": "string", "format": "email", "example": "admin@test.com"}, + "password": map[string]any{"type": "string", "format": "password", "example": "password123"}, + }, + }, + "AuthResponse": map[string]any{ + "type": "object", + "required": []string{"access_token", "token_type", "expires_in", "user"}, + "properties": map[string]any{ + "access_token": map[string]any{ + "type": "string", + "description": "JWT access token — include in Authorization: Bearer ", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }, + "token_type": map[string]any{"type": "string", "example": "Bearer"}, + "expires_in": map[string]any{"type": "integer", "description": "Seconds", "example": 900}, + "user": ref("User"), + }, + }, + "User": map[string]any{ + "type": "object", + "required": []string{"id", "company_id", "email", "role"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string", "format": "uuid", "example": "018e2f3a-1b2c-7d4e-8f5a-9b0c1d2e3f4a"}, + "company_id": map[string]any{"type": "string", "format": "uuid", "example": "018e2f3a-0000-7d4e-8f5a-9b0c1d2e3f4a"}, + "email": map[string]any{"type": "string", "format": "email", "example": "admin@test.com"}, + "role": map[string]any{ + "type": "string", + "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin", "super_admin"}, + "example": "admin", + }, + }, + }, + "ErrorResponse": map[string]any{ + "type": "object", + "required": []string{"error"}, + "properties": map[string]any{ + "error": map[string]any{"type": "string", "example": "invalid credentials"}, + }, + }, + }, + }, + } +} + +func ref(name string) map[string]any { + return map[string]any{"$ref": "#/components/schemas/" + name} +} + +func errorResponse(example string) map[string]any { + return map[string]any{ + "description": example, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": ref("ErrorResponse"), + "example": map[string]any{"error": example}, + }, + }, + } +} + +func refreshCookieHeader() map[string]any { + return map[string]any{ + "Set-Cookie": map[string]any{ + "description": "HttpOnly refresh token cookie.", + "schema": map[string]any{"type": "string"}, + "example": "refresh_token=abc123; Path=/auth; HttpOnly; SameSite=Lax", + }, + } +} diff --git a/api/pkg/config/config.go b/api/pkg/config/config.go new file mode 100644 index 0000000..7b28729 --- /dev/null +++ b/api/pkg/config/config.go @@ -0,0 +1,115 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// all environment +type Config struct { + // server + Port string + AppEnv string + + // db + DatabaseURL string + + // hasura, graphql access + HasuraEndpoint string + HasuraAdminSecret string + + // jwt + JWTSecret string + JWTAccessExpiry time.Duration + JWTRefreshExpiry time.Duration + + // // Email (Postmark) + // PostmarkAPIKey string + // PostmarkFrom string + // + // // SMS (Twilio) — Critical alerts only + // TwilioAccountSID string + // TwilioAuthToken string + // TwilioFromNumber string +} + +func Load() *Config { + cfg := &Config{ + Port: getEnv("PORT", "8080"), + AppEnv: getEnv("APP_ENV", "development"), + DatabaseURL: require("DATABASE_URL"), + HasuraEndpoint: require("HASURA_ENDPOINT"), + HasuraAdminSecret: require("HASURA_ADMIN_SECRET"), + JWTSecret: require("JWT_SECRET"), + JWTAccessExpiry: parseDuration("JWT_ACCESS_EXPIRY", 15*time.Minute), + JWTRefreshExpiry: parseDuration("JWT_REFRESH_EXPIRY", 7*24*time.Hour), + + // // External services — optional locally, required in production. + // // The service checks these at call time and skips gracefully if empty. + // PostmarkAPIKey: getEnv("POSTMARK_API_KEY", ""), + // PostmarkFrom: getEnv("POSTMARK_FROM", "noreply@localhost"), + // TwilioAccountSID: getEnv("TWILIO_ACCOUNT_SID", ""), + // TwilioAuthToken: getEnv("TWILIO_AUTH_TOKEN", ""), + // TwilioFromNumber: getEnv("TWILIO_FROM_NUMBER", ""), + } + // jwt secret must be at least 32 characters for HS256 + if len(cfg.JWTSecret) < 32 { + fatalf("JWT_SECRET must be at least 32 characters (got %d)", len(cfg.JWTSecret)) + } + return cfg +} + +func (c *Config) IsDev() bool { + return c.AppEnv == "development" +} + +// require reads an environment variable and exits if it is empty or unset +func require(key string) string { + v := os.Getenv(key) + if v == "" { + fatalf("required environment variable %q is not set", key) + } + return v +} + +// getEnv reads an environment variable with a fallback default value +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// parseDuration reads a duration string (e.g. "15m", "168h") +func parseDuration(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil { + fatalf("environment variable %q has invalid duration value %q: %v", key, v, err) + } + return d +} + +// parseInt reads an integer from an environment variable +// used for pool sizes, port numbers... +func parseInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + fatalf("environment variable %q has invalid integer value %q: %v", key, v, err) + } + return n +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "config: "+format+"\n", args...) + os.Exit(1) +} diff --git a/api/pkg/database/database.go b/api/pkg/database/database.go new file mode 100644 index 0000000..758f49d --- /dev/null +++ b/api/pkg/database/database.go @@ -0,0 +1,56 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Pool is the application-wide Postgres connection pool. +// pgxpool is safe for concurrent use — share one pool across all handlers. +type Pool = pgxpool.Pool + +// Connect opens a connection pool to Postgres and verifies connectivity +// with a ping. Returns an error if the database is unreachable. +// +// The pool is configured conservatively for the current scale: +// - Max 10 connections (matches HASURA_GRAPHQL_PG_CONNECTIONS in compose) +// - Connections idle for >5 minutes are closed +// - Connections older than 1 hour are recycled +// +// Adjust MaxConns based on your Postgres server's max_connections setting. +// A safe rule: total app connections < (max_connections × 0.8). +func Connect(ctx context.Context, databaseURL string) (*Pool, error) { + cfg, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("database: parse config: %w", err) + } + + // Connection pool settings + cfg.MaxConns = 10 + cfg.MinConns = 2 + cfg.MaxConnIdleTime = 5 * time.Minute + cfg.MaxConnLifetime = 1 * time.Hour + + // ConnectTimeout applies to acquiring a connection from the pool. + // If all connections are in use and none become available within + // this duration, the query returns an error instead of waiting forever. + cfg.ConnConfig.ConnectTimeout = 5 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("database: create pool: %w", err) + } + + // Verify the connection is actually alive. + // Fails fast at startup rather than during the first real request. + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("database: ping failed: %w", err) + } + + return pool, nil +} + diff --git a/biome.json b/biome.json deleted file mode 100644 index 0faf516..0000000 --- a/biome.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true, - "defaultBranch": "main" - }, - "css": { - "parser": { "cssModules": true } - }, - "javascript": { - "formatter": { - "semicolons": "asNeeded", - "quoteStyle": "single", - "arrowParentheses": "asNeeded" - } - }, - "formatter": { - "indentStyle": "space" - }, - "assist": { - "actions": { - "source": { - "organizeImports": { - "options": { - "groups": [":NODE:", ":PACKAGE:", ":ALIAS:", ":PATH:"] - } - } - } - } - }, - "linter": { - "enabled": true, - "rules": { - "nursery": { - "useUniqueElementIds": "off" - }, - "a11y": { - "noStaticElementInteractions": "off", - "useSemanticElements": "off" - }, - "suspicious": { - "noAssignInExpressions": "off" - }, - "correctness": { - "useExhaustiveDependencies": "off", - "noUnusedFunctionParameters": "error", - "noUnusedVariables": "error", - "noUnusedImports": "error" - }, - "style": { - "noParameterAssign": "off", - "useSingleVarDeclarator": "off", - "noUnusedTemplateLiteral": "off", - "useAsConstAssertion": "error", - "useDefaultParameterLast": "error", - "useEnumInitializers": "error", - "useSelfClosingElements": "error", - "useNumberNamespace": "error", - "noInferrableTypes": "error", - "noUselessElse": "error" - } - } - } -} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..141275a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,171 @@ +# Boot order (enforced by depends_on + healthchecks): +# 1. postgres — database must be accepting connections +# 2. hasura — GraphQL engine starts after schema exists +# 3. api + front — start in parallel once hasura is healthy +# Ports: +# 5432 → postgres (connect with any SQL client) +# 8080 → hasura (GraphQL endpoint + console UI) +# 8081 → api (Go REST API) +# 5173 → front (Vite dev server with HMR) +services: + postgres: + image: postgres:18-alpine + container_name: operafix_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-operafix} + POSTGRES_USER: ${POSTGRES_USER:-operafix} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-operafix_dev_password} + volumes: + - postgres_data:/var/lib/postgresql + # init-postgres.sql runs ONCE on first boot (when the volume is empty) + # it enables PostgreSQL extensions that require superuser privileges: + # pgcrypto → gen_random_uuid() used by every table's PK default + # pg_trgm → fast fuzzy search on equipment names and issue titles + # btree_gist → exclusion constraints for PM schedule overlap prevention + - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + ports: + - "5432:5432" + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-operafix} -d ${POSTGRES_DB:-operafix}", + ] + interval: 5s + timeout: 5s + retries: 10 + networks: + - operafix_net + + hasura: + build: + context: ./hasura + dockerfile: Dockerfile + target: development + container_name: operafix_hasura + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # >- is YAML block scalar: joins lines into one string without newline + # to avoid line breaks inside the URL + HASURA_GRAPHQL_DATABASE_URL: >- + postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD:-operafix_dev_password}@postgres:5432/${POSTGRES_DB:-operafix} + # TODO: change the secrets hehe + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-pNZxIfzAD43hka0HrR02JAmsZmLU10bI} + HASURA_GRAPHQL_JWT_SECRET: >- + {"type":"HS256","key":"${JWT_SECRET:-kK2DUgOa2HZJseW1YpbpTnx1nN2nzOsC4Ar3ZVZc4H3xQLwA0e1nwU5sCeXERwbO}"} + HASURA_GRAPHQL_ENABLE_CONSOLE: "true" + HASURA_GRAPHQL_DEV_MODE: "true" + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log,websocket-log,query-log + HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata + HASURA_GRAPHQL_MIGRATIONS_DIR: /hasura-migrations + # when a DB event fires or an action is invoked, Hasura POSTs to: + # http://api:8080/ + # "api" resolves to the Go API container on the Docker network + ACTION_BASE_URL: ${ACTION_BASE_URL:-http://api:8080} + # max simultaneous postgres connections hasura will hold open + HASURA_GRAPHQL_PG_CONNECTIONS: "10" + HASURA_GRAPHQL_TX_ISOLATION: serializable + volumes: + - ./hasura/metadata:/hasura-metadata:ro + - ./hasura/migrations:/hasura-migrations:ro + ports: + - "8080:8080" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 15 + # give Hasura 20s to start before the first health check fires + # it needs time to connect to Postgres and apply metadata + start_period: 20s + networks: + - operafix_net + + api: + build: + context: ./api + dockerfile: Dockerfile + # runs `air` for hot-reload + target: development + container_name: operafix_api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + hasura: + # go API must start after Hasura is ready + condition: service_healthy + environment: + DATABASE_URL: >- + postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD:-operafix_dev_password}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=disable + # hasura internal endpoint for privileged GraphQL queries + HASURA_ENDPOINT: http://hasura:8080/v1/graphql + HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-pNZxIfzAD43hka0HrR02JAmsZmLU10bI} + # JWT signing key, must be identical to Hasura's JWT secret above + JWT_SECRET: ${JWT_SECRET:-kK2DUgOa2HZJseW1YpbpTnx1nN2nzOsC4Ar3ZVZc4H3xQLwA0e1nwU5sCeXERwbO} + # short-lived, rotate via refresh + JWT_ACCESS_EXPIRY: ${JWT_ACCESS_EXPIRY:-15m} + # 7 days, stored in HttpOnly cookie + JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY:-168h} + APP_ENV: development + LOG_LEVEL: debug + PORT: 8080 + # # External services — leave blank locally unless actively testing + # # that feature. The Go service skips sending when these are empty. + # POSTMARK_API_KEY: ${POSTMARK_API_KEY:-} + # POSTMARK_FROM: ${POSTMARK_FROM:-noreply@localhost} + # TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-} + # TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-} + # TWILIO_FROM_NUMBER: ${TWILIO_FROM_NUMBER:-} + # R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + # R2_ACCESS_KEY: ${R2_ACCESS_KEY:-} + # R2_SECRET_KEY: ${R2_SECRET_KEY:-} + # R2_BUCKET: ${R2_BUCKET:-operafix-media} + # R2_PUBLIC_URL: ${R2_PUBLIC_URL:-http://localhost:9000} + volumes: + # mount api/ source into the container so `air` detects file changes + # :delegated, on macOS, relaxes mount consistency for better performance + - ./api:/app:delegated + # avoids re-downloading all dependencies on every `docker compose build` + - go_mod_cache:/root/go/pkg/mod + ports: + - "8081:8080" + networks: + - operafix_net + + front: + build: + context: ./front + dockerfile: Dockerfile + target: development + container_name: operafix_front + restart: unless-stopped + depends_on: + hasura: + condition: service_healthy + environment: + # VITE_* prefix is mandatory, vite only injects variables with this + VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL:-http://localhost:8080/v1/graphql} + VITE_GRAPHQL_WS: ${VITE_GRAPHQL_WS:-ws://localhost:8080/v1/graphql} + # go API base URL + VITE_API_URL: ${VITE_API_URL:-http://localhost:8081} + volumes: + # mount front/ source so Vite's file watcher picks up changes + - ./front:/app:delegated + - front_node_modules:/app/node_modules + ports: + - "5173:5173" + networks: + - operafix_net + +volumes: + postgres_data: + go_mod_cache: + front_node_modules: +networks: + operafix_net: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..db28436 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,162 @@ +services: + postgres: + image: postgres:18-alpine + container_name: operafix_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-operafix} + POSTGRES_USER: ${POSTGRES_USER:-operafix} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql + - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + # no ports, postgres is internal only in production we can access it directly + # on the server, in the future we can expose it + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-operafix} -d ${POSTGRES_DB:-operafix}", + ] + interval: 10s + timeout: 5s + retries: 10 + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + networks: + - operafix_net + + hasura: + build: + context: ./hasura + dockerfile: Dockerfile + target: production + container_name: operafix_hasura + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + HASURA_GRAPHQL_DATABASE_URL: >- + postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix} + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} + HASURA_GRAPHQL_JWT_SECRET: >- + {"type":"HS256","key":"${JWT_SECRET}"} + HASURA_GRAPHQL_ENABLE_CONSOLE: "false" + HASURA_GRAPHQL_DEV_MODE: "false" + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log + HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata + HASURA_GRAPHQL_MIGRATIONS_DIR: /hasura-migrations + ACTION_BASE_URL: http://api:8080 + HASURA_GRAPHQL_PG_CONNECTIONS: "25" + HASURA_GRAPHQL_TX_ISOLATION: serializable + volumes: + - ./hasura/migrations:/hasura-migrations:ro + - ./hasura/metadata:/hasura-metadata:ro + # no ports, internal only. Caddy proxies /v1/* → hasura:8080 + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + networks: + - operafix_net + + api: + build: + context: ./api + dockerfile: Dockerfile + target: production + args: + VERSION: ${VERSION:-unknown} + COMMIT: ${COMMIT:-unknown} + BUILD_TIME: ${BUILD_TIME:-unknown} + container_name: operafix_api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + hasura: + condition: service_healthy + environment: + DATABASE_URL: >- + postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=disable + HASURA_ENDPOINT: http://hasura:8080/v1/graphql + HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} + JWT_SECRET: ${JWT_SECRET} + JWT_ACCESS_EXPIRY: ${JWT_ACCESS_EXPIRY:-15m} + JWT_REFRESH_EXPIRY: ${JWT_REFRESH_EXPIRY:-168h} + APP_ENV: production + LOG_LEVEL: info + PORT: 8080 + # POSTMARK_API_KEY: ${POSTMARK_API_KEY} + # POSTMARK_FROM: ${POSTMARK_FROM} + # TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID} + # TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN} + # TWILIO_FROM_NUMBER: ${TWILIO_FROM_NUMBER} + # R2_ACCOUNT_ID: ${R2_ACCOUNT_ID} + # R2_ACCESS_KEY: ${R2_ACCESS_KEY} + # R2_SECRET_KEY: ${R2_SECRET_KEY} + # R2_BUCKET: ${R2_BUCKET:-operafix-media} + # R2_PUBLIC_URL: ${R2_PUBLIC_URL} + + # no ports, internal only. Caddy proxies /api/* → api:8080 + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M + networks: + - operafix_net + + front: + build: + context: ./front + dockerfile: Dockerfile + target: production + args: + VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL} + VITE_GRAPHQL_WS: ${VITE_GRAPHQL_WS} + VITE_API_URL: ${VITE_API_URL} + container_name: operafix_front + restart: unless-stopped + depends_on: + hasura: + condition: service_healthy + api: + condition: service_started + environment: + DOMAIN: ${DOMAIN:-:80} + ports: + - "80:80" + - "443:443" + volumes: + - caddy_data:/data + - caddy_config:/config + deploy: + resources: + limits: + memory: 128M + reservations: + memory: 64M + networks: + - operafix_net + +volumes: + postgres_data: + +networks: + operafix_net: + driver: bridge diff --git a/front/Caddyfile b/front/Caddyfile new file mode 100644 index 0000000..5259d93 --- /dev/null +++ b/front/Caddyfile @@ -0,0 +1,56 @@ +{$DOMAIN:":80"} { + encode zstd gzip + log { + output stdout + format json + } + handle /api/* { + uri strip_prefix /api + reverse_proxy api:8080 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + transport http { + dial_timeout 5s + response_header_timeout 30s + } + } + } + + handle /v1/* { + reverse_proxy hasura:8080 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + transport http { + dial_timeout 5s + response_header_timeout 30s + } + } + } + + handle { + root * /srv + try_files {path} /index.html + file_server + @fingerprinted { + path_regexp .*\.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|jpeg|svg|ico|webp)$ + } + header @fingerprinted Cache-Control "public, max-age=31536000, immutable" + @html { + path *.html + } + header @html Cache-Control "no-store" + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + X-XSS-Protection "1; mode=block" + -Server + } +} diff --git a/front/Dockerfile b/front/Dockerfile new file mode 100644 index 0000000..9ab08a3 --- /dev/null +++ b/front/Dockerfile @@ -0,0 +1,49 @@ +# Stages: +# base → shared foundation (node_modules installed) +# development → Vite dev server with HMR (source mounted as volume) +# builder → compiles optimised static assets via `npm run build` +# production → Caddy serving the static build from /srv + +FROM node:24-alpine AS base + +WORKDIR /app +COPY package.json package-lock.json ./ + +# TODO: could we avoid peer-deps?? Vite will not allow it since we want to use PWA? +RUN npm i --legacy-peer-deps + +# Used by docker-compose.dev.yml. +# Does NOT copy source: source is mounted as a live volume at runtime: +# volumes: +# - ./front:/app:delegated +# - front_node_modules:/app/node_modules +FROM base AS development + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + +FROM base AS builder + +COPY . . + +ARG VITE_GRAPHQL_URL +ARG VITE_GRAPHQL_WS +ARG VITE_API_URL +ENV VITE_GRAPHQL_URL=$VITE_GRAPHQL_URL +ENV VITE_GRAPHQL_WS=$VITE_GRAPHQL_WS +ENV VITE_API_URL=$VITE_API_URL + +# Run the Vite production build. +RUN npm run build + +FROM caddy:2-alpine AS production + +COPY Caddyfile /etc/caddy/Caddyfile +# only the dist/ contents are copied, we already builed it previously +COPY --from=builder /app/dist /srv + +# caddy listens on port 80 inside the container. +EXPOSE 80 + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/front/deno.json b/front/deno.json new file mode 100644 index 0000000..c857878 --- /dev/null +++ b/front/deno.json @@ -0,0 +1,34 @@ +{ + "fmt": { + "useTabs": false, + "indentWidth": 2, + "lineWidth": 100, + "semiColons": false, + "singleQuote": true, + "proseWrap": "preserve" + }, + + "lint": { + "rules": { + "tags": ["recommended"], + "exclude": ["no-window", "no-window-prefix"] + } + }, + + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + + "exclude": [ + "node_modules", + "dist", + "build", + "hasura", + "docker-compose.dev.yml", + "docker-compose.prod.yml" + ] +} diff --git a/index.html b/front/index.html similarity index 98% rename from index.html rename to front/index.html index 5cb5685..e3b0085 100644 --- a/index.html +++ b/front/index.html @@ -1,4 +1,4 @@ - + diff --git a/package-lock.json b/front/package-lock.json similarity index 100% rename from package-lock.json rename to front/package-lock.json diff --git a/package.json b/front/package.json similarity index 100% rename from package.json rename to front/package.json diff --git a/postcss.config.js b/front/postcss.config.js similarity index 100% rename from postcss.config.js rename to front/postcss.config.js diff --git a/public/android-chrome-192x192.png b/front/public/android-chrome-192x192.png similarity index 100% rename from public/android-chrome-192x192.png rename to front/public/android-chrome-192x192.png diff --git a/public/android-chrome-512x512.png b/front/public/android-chrome-512x512.png similarity index 100% rename from public/android-chrome-512x512.png rename to front/public/android-chrome-512x512.png diff --git a/public/apple-touch-icon.png b/front/public/apple-touch-icon.png similarity index 100% rename from public/apple-touch-icon.png rename to front/public/apple-touch-icon.png diff --git a/public/favicon-16x16.png b/front/public/favicon-16x16.png similarity index 100% rename from public/favicon-16x16.png rename to front/public/favicon-16x16.png diff --git a/public/favicon-32x32.png b/front/public/favicon-32x32.png similarity index 100% rename from public/favicon-32x32.png rename to front/public/favicon-32x32.png diff --git a/public/operafix_logo.png b/front/public/operafix_logo.png similarity index 100% rename from public/operafix_logo.png rename to front/public/operafix_logo.png diff --git a/src/App.tsx b/front/src/App.tsx similarity index 69% rename from src/App.tsx rename to front/src/App.tsx index f32f085..48982d4 100644 --- a/src/App.tsx +++ b/front/src/App.tsx @@ -1,30 +1,25 @@ import type React from 'react' -import { - BrowserRouter as Router, - Routes, - Route, - Navigate, -} from 'react-router-dom' -import { ThemeProvider } from './context/ThemeContext' -import { ToastProvider } from './context/ToastContext' -import { AuthProvider, useAuth } from './context/AuthContext' -import { Layout } from './components/Layout' -import { Login } from './pages/Login' -import { Dashboard } from './pages/Dashboard' -import { Settings } from './pages/Settings' -import { LocationsList } from './pages/Locations/LocationsList' -import { EquipmentList } from './pages/Equipment/EquipmentList' -import { EquipmentInfo } from './pages/Equipment/EquipmentInfo' -import { ReportCreation } from './pages/Reports/ReportCreation' -import { ReportDetail } from './pages/Reports/ReportDetail' -import { ReportsList } from './pages/Reports/ReportsList' -import { QRScanner } from './pages/QRScanner' -import { TechnicianAssignment } from './pages/Technicians/TechnicianAssignment' -import { Analytics } from './pages/Analytics' -import { PreventiveList } from './pages/Preventive/PreventiveList' -import { PWAInstallPrompt } from './components/PWAInstallPrompt' -import { Docs } from './pages/Docs' -import type { Role } from './types' +import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom' +import { ThemeProvider } from './context/ThemeContext.tsx' +import { ToastProvider } from './context/ToastContext.tsx' +import { AuthProvider, useAuth } from './context/AuthContext.tsx' +import { Layout } from './components/Layout.tsx' +import { Login } from './pages/Login.tsx' +import { Dashboard } from './pages/Dashboard.tsx' +import { Settings } from './pages/Settings.tsx' +import { LocationsList } from './pages/Locations/LocationsList.tsx' +import { EquipmentList } from './pages/Equipment/EquipmentList.tsx' +import { EquipmentInfo } from './pages/Equipment/EquipmentInfo.tsx' +import { ReportCreation } from './pages/Reports/ReportCreation.tsx' +import { ReportDetail } from './pages/Reports/ReportDetail.tsx' +import { ReportsList } from './pages/Reports/ReportsList.tsx' +import { QRScanner } from './pages/QRScanner.tsx' +import { TechnicianAssignment } from './pages/Technicians/TechnicianAssignment.tsx' +import { Analytics } from './pages/Analytics.tsx' +import { PreventiveList } from './pages/Preventive/PreventiveList.tsx' +import { PWAInstallPrompt } from './components/PWAInstallPrompt.tsx' +import { Docs } from './pages/Docs/index.tsx' +import type { Role } from './types/index.ts' /** * Higher-order component to restrict access to specific routes. @@ -40,22 +35,24 @@ const ProtectedRoute = ({ const { user, isLoading } = useAuth() // Display a loading screen while authentication status is being determined - if (isLoading) + if (isLoading) { return ( -
-
-
+
+
+
Initializing Precision Terminal...
) + } // Redirect to login if not authenticated - if (!user) return + if (!user) return // Redirect to home if the user's role is not permitted for this route - if (allowedRoles && !allowedRoles.includes(user.role)) - return + if (allowedRoles && !allowedRoles.includes(user.role)) { + return + } return <>{children} } @@ -71,11 +68,11 @@ function App() { {/* Public Routes */} - } /> + } /> {/* Protected Routes wrapped with Layout */} @@ -86,7 +83,7 @@ function App() { /> @@ -96,7 +93,7 @@ function App() { } /> @@ -107,7 +104,7 @@ function App() { /> @@ -117,7 +114,7 @@ function App() { } /> @@ -127,7 +124,7 @@ function App() { } /> @@ -138,7 +135,7 @@ function App() { /> @@ -149,7 +146,7 @@ function App() { /> @@ -178,7 +175,7 @@ function App() { /> @@ -202,7 +199,7 @@ function App() { /> @@ -214,7 +211,7 @@ function App() { {/* Docs - Hidden */} @@ -225,7 +222,7 @@ function App() { /> {/* Catch-all redirect to home */} - } /> + } /> diff --git a/src/components/Badge.tsx b/front/src/components/Badge.tsx similarity index 92% rename from src/components/Badge.tsx rename to front/src/components/Badge.tsx index 7d9201a..7b00590 100644 --- a/src/components/Badge.tsx +++ b/front/src/components/Badge.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' -import { cn } from '../lib/utils' -import type { IssueStatus, IssueSeverity, EquipmentStatus } from '../types' +import { cn } from '../lib/utils.ts' +import type { EquipmentStatus, IssueSeverity, IssueStatus } from '../types/index.ts' /** * Union type for all supported status and severity types. diff --git a/src/components/Button.tsx b/front/src/components/Button.tsx similarity index 89% rename from src/components/Button.tsx rename to front/src/components/Button.tsx index cbe93a8..7f21752 100644 --- a/src/components/Button.tsx +++ b/front/src/components/Button.tsx @@ -1,6 +1,6 @@ -import type { ReactNode, FC } from 'react' -import { motion, type HTMLMotionProps } from 'framer-motion' -import { cn } from '../lib/utils' +import type { FC, ReactNode } from 'react' +import { type HTMLMotionProps, motion } from 'framer-motion' +import { cn } from '../lib/utils.ts' /** * Configuration for our versatile Button component. @@ -40,8 +40,7 @@ export const Button: FC = ({ secondary: 'border border-border bg-surface text-primary hover:bg-primary/5 active:bg-primary/10 shadow-sm', tertiary: 'bg-transparent text-secondary hover:text-on-surface', - outline: - 'border border-border bg-surface text-on-surface hover:bg-surface-container shadow-sm', + outline: 'border border-border bg-surface text-on-surface hover:bg-surface-container shadow-sm', ghost: 'hover:bg-surface-container text-on-surface', destructive: 'bg-error text-white hover:opacity-90 shadow-sm', soft: 'bg-primary/10 text-primary hover:bg-primary/20', diff --git a/src/components/Card.tsx b/front/src/components/Card.tsx similarity index 92% rename from src/components/Card.tsx rename to front/src/components/Card.tsx index f9991e9..bebb412 100644 --- a/src/components/Card.tsx +++ b/front/src/components/Card.tsx @@ -1,6 +1,6 @@ -import type { ReactNode, FC } from 'react' -import { motion, type HTMLMotionProps } from 'framer-motion' -import { cn } from '../lib/utils' +import type { FC, ReactNode } from 'react' +import { type HTMLMotionProps, motion } from 'framer-motion' +import { cn } from '../lib/utils.ts' /** * Define the properties for our Card component. @@ -111,9 +111,7 @@ export const CardDescription: FC<{ export const CardContent: FC<{ children: ReactNode className?: string -}> = ({ children, className }) => ( -
{children}
-) +}> = ({ children, className }) =>
{children}
/** * Footer area for cards, useful for actions or metadata. diff --git a/src/components/Input.tsx b/front/src/components/Input.tsx similarity index 76% rename from src/components/Input.tsx rename to front/src/components/Input.tsx index f6b2464..3e5491c 100644 --- a/src/components/Input.tsx +++ b/front/src/components/Input.tsx @@ -1,11 +1,10 @@ import React from 'react' -import { cn } from '../lib/utils' +import { cn } from '../lib/utils.ts' /** * Standard text input component with a label and error message. */ -export interface InputProps - extends React.InputHTMLAttributes { +export interface InputProps extends React.InputHTMLAttributes { label?: string error?: string } @@ -15,11 +14,11 @@ export const Input = React.forwardRef( const fallbackId = React.useId() const inputId = id || fallbackId return ( -
+
{label && ( @@ -36,7 +35,7 @@ export const Input = React.forwardRef( {...props} /> {error && ( -

+

{error}

)} @@ -49,8 +48,7 @@ Input.displayName = 'Input' /** * Larger text area component for long-form input. */ -export interface TextareaProps - extends React.TextareaHTMLAttributes { +export interface TextareaProps extends React.TextareaHTMLAttributes { label?: string error?: string } @@ -60,11 +58,11 @@ export const Textarea = React.forwardRef( const fallbackId = React.useId() const textareaId = id || fallbackId return ( -
+
{label && ( @@ -80,7 +78,7 @@ export const Textarea = React.forwardRef( {...props} /> {error && ( -

+

{error}

)} @@ -93,8 +91,7 @@ Textarea.displayName = 'Textarea' /** * Dropdown selection component. */ -export interface SelectProps - extends React.SelectHTMLAttributes { +export interface SelectProps extends React.SelectHTMLAttributes { label?: string error?: string children: React.ReactNode @@ -105,11 +102,11 @@ export const Select = React.forwardRef( const fallbackId = React.useId() const selectId = id || fallbackId return ( -
+
{label && ( @@ -127,7 +124,7 @@ export const Select = React.forwardRef( {children} {error && ( -

+

{error}

)} diff --git a/src/components/Layout.tsx b/front/src/components/Layout.tsx similarity index 66% rename from src/components/Layout.tsx rename to front/src/components/Layout.tsx index a3b1269..3aa4d2c 100644 --- a/src/components/Layout.tsx +++ b/front/src/components/Layout.tsx @@ -1,29 +1,29 @@ -import { useState, useEffect, useMemo, type ReactNode, type FC } from 'react' -import type { Role } from '../types' -import { useTheme } from '../context/ThemeContext' +import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react' +import type { Role } from '../types/index.ts' +import { useTheme } from '../context/ThemeContext.tsx' import { - Moon, - Sun, - LayoutDashboard, - Wrench, + BarChart3, ClipboardList, + Globe, + LayoutDashboard, + LogOut, MapPin, - BarChart3, - Settings as SettingsIcon, - Search, Menu, - X, + Moon, QrCode, + Search, + Settings as SettingsIcon, + Sun, User as UserIcon, - LogOut, Users, - Globe, WifiOff, + Wrench, + X, } from 'lucide-react' -import { useAuth } from '../context/AuthContext' +import { useAuth } from '../context/AuthContext.tsx' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { Button } from './Button' -import { cn } from '../lib/utils' +import { Button } from './Button.tsx' +import { cn } from '../lib/utils.ts' import { AnimatePresence, motion } from 'framer-motion' import { useTranslation } from 'react-i18next' @@ -137,7 +137,7 @@ export const Layout: FC = ({ children }) => { const filteredNavItems = useMemo(() => { if (!user) return [] return navItems.filter( - item => !item.allowedRoles || item.allowedRoles.includes(user.role), + (item) => !item.allowedRoles || item.allowedRoles.includes(user.role), ) }, [user, navItems]) @@ -165,9 +165,9 @@ export const Layout: FC = ({ children }) => { initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} - className="bg-error text-white w-full overflow-hidden z-50 relative shadow-md" + className='bg-error text-white w-full overflow-hidden z-50 relative shadow-md' > -
+
{t('common.offline_warning')}
@@ -177,9 +177,10 @@ export const Layout: FC = ({ children }) => { {/* Decorative spotlight effect that follows the cursor on desktop screens */} {isDesktop && (
)} @@ -188,24 +189,22 @@ export const Layout: FC = ({ children }) => {
-
-
- -
+
+
+ +
- + OperaFix -
-
-
+
+
-
+
-
+
- + {user?.name} - + {user?.role.replace('_', ' ')} -
-
- {user?.avatar ? ( - {`${user.name}'s - ) : ( - - )} +
+
+ {user?.avatar + ? ( + {`${user.name}'s + ) + : ( + + )}
-
-
-

+

+
+

{t('common.profile')}

- {' '} - {t('common.settings')} + {t('common.settings')} @@ -307,9 +310,9 @@ export const Layout: FC = ({ children }) => {
-
- {filteredNavItems.map(item => ( +
+ {filteredNavItems.map((item) => ( = ({ children }) => { ))} setIsMobileMenuOpen(false)} className={cn( 'flex items-center gap-4 px-4 py-3.5 text-xs font-black uppercase tracking-widest rounded-xl transition-all border border-transparent', @@ -402,31 +405,31 @@ export const Layout: FC = ({ children }) => {
-
-
-
- {user?.avatar ? ( - {`${user.name}'s - ) : ( - - )} +
+
+
+ {user?.avatar + ? ( + {`${user.name}'s + ) + : }
-

+

{user?.name}

-

+

{user?.role}

{/* Basic footer for desktop screens */} -