From 53a8e765c1ea42c7a1d2763b918b15b2543687cd Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 11 Apr 2026 21:07:15 +0100 Subject: [PATCH 01/18] feat(api|front|db): create micro services system, implement docker compose + hasura implementation with postgres + api to come --- SYSTEM_DESIGN_README.md | 312 ++++++++++++++++++ api/.air.toml | 26 ++ api/Dockerfile | 134 ++++++++ api/go.mod | 3 + docker-compose.dev.yml | 310 +++++++++++++++++ docker-compose.prod.yml | 226 +++++++++++++ front/Caddyfile | 36 ++ front/Dockerfile | 129 ++++++++ index.html => front/index.html | 0 package-lock.json => front/package-lock.json | 0 package.json => front/package.json | 0 postcss.config.js => front/postcss.config.js | 0 .../public}/android-chrome-192x192.png | Bin .../public}/android-chrome-512x512.png | Bin {public => front/public}/apple-touch-icon.png | Bin {public => front/public}/favicon-16x16.png | Bin {public => front/public}/favicon-32x32.png | Bin {public => front/public}/operafix_logo.png | Bin {src => front/src}/App.tsx | 0 {src => front/src}/components/Badge.tsx | 0 {src => front/src}/components/Button.tsx | 0 {src => front/src}/components/Card.tsx | 0 {src => front/src}/components/Input.tsx | 0 {src => front/src}/components/Layout.tsx | 0 .../src}/components/PWAInstallPrompt.tsx | 0 {src => front/src}/components/StatsCard.tsx | 0 {src => front/src}/components/Table.tsx | 0 {src => front/src}/context/AuthContext.tsx | 0 {src => front/src}/context/ThemeContext.tsx | 0 {src => front/src}/context/ToastContext.tsx | 0 {src => front/src}/data/mockData.ts | 0 {src => front/src}/i18n.ts | 0 {src => front/src}/index.css | 0 {src => front/src}/lib/utils.ts | 0 {src => front/src}/locales/en-GB.json | 0 {src => front/src}/locales/pt-PT.json | 0 {src => front/src}/main.tsx | 0 {src => front/src}/pages/Analytics.tsx | 0 {src => front/src}/pages/Dashboard.tsx | 0 .../src}/pages/Equipment/EquipmentInfo.tsx | 0 .../src}/pages/Equipment/EquipmentList.tsx | 0 .../src}/pages/Locations/LocationsList.tsx | 0 {src => front/src}/pages/Login.tsx | 0 .../src}/pages/Preventive/PreventiveList.tsx | 0 {src => front/src}/pages/QRScanner.tsx | 0 .../src}/pages/Reports/ReportCreation.tsx | 0 .../src}/pages/Reports/ReportDetail.tsx | 0 .../src}/pages/Reports/ReportsList.tsx | 0 {src => front/src}/pages/Settings.tsx | 0 .../Technicians/TechnicianAssignment.tsx | 0 {src => front/src}/types/index.ts | 0 {src => front/src}/vite-env.d.ts | 0 .../tailwind.config.js | 0 tsconfig.json => front/tsconfig.json | 0 vite.config.ts => front/vite.config.ts | 0 hasura/Dockerfile | 111 +++++++ hasura/config.yaml | 30 ++ hasura/metadata/actions.graphql | 0 hasura/metadata/actions.yaml | 6 + hasura/metadata/allow_list.yaml | 1 + hasura/metadata/api_limits.yaml | 1 + hasura/metadata/backend_configs.yaml | 1 + hasura/metadata/cron_triggers.yaml | 1 + hasura/metadata/databases/databases.yaml | 14 + .../default/tables/public_companies.yaml | 74 +++++ .../default/tables/public_equipment.yaml | 52 +++ .../tables/public_equipment_analytics.yaml | 7 + .../tables/public_equipment_categories.yaml | 15 + .../tables/public_equipment_photos.yaml | 7 + .../tables/public_industry_templates.yaml | 3 + .../tables/public_issue_categories.yaml | 18 + .../default/tables/public_issue_comments.yaml | 10 + .../default/tables/public_issue_photos.yaml | 10 + .../default/tables/public_issues.yaml | 54 +++ .../default/tables/public_location_types.yaml | 15 + .../default/tables/public_locations.yaml | 45 +++ .../tables/public_maintenance_actions.yaml | 18 + .../tables/public_notifications_log.yaml | 13 + .../default/tables/public_parts_used.yaml | 7 + .../tables/public_preventive_schedules.yaml | 10 + .../tables/public_preventive_tasks.yaml | 18 + .../tables/public_severity_levels.yaml | 22 ++ .../default/tables/public_users.yaml | 67 ++++ .../databases/default/tables/tables.yaml | 19 ++ .../graphql_schema_introspection.yaml | 1 + hasura/metadata/inherited_roles.yaml | 1 + hasura/metadata/metrics_config.yaml | 1 + hasura/metadata/network.yaml | 1 + hasura/metadata/opentelemetry.yaml | 1 + hasura/metadata/query_collections.yaml | 1 + hasura/metadata/remote_schemas.yaml | 1 + hasura/metadata/rest_endpoints.yaml | 1 + hasura/metadata/version.yaml | 1 + hasura/migrations/001_companies.down.sql | 1 + hasura/migrations/001_companies.up.sql | 14 + hasura/migrations/002_location_types.down.sql | 1 + hasura/migrations/002_location_types.up.sql | 13 + hasura/migrations/003_locations.down.sql | 1 + hasura/migrations/003_locations.up.sql | 30 ++ .../migrations/004_severity_levels.down.sql | 1 + hasura/migrations/004_severity_levels.up.sql | 16 + .../migrations/005_issue_categories.down.sql | 1 + hasura/migrations/005_issue_categories.up.sql | 13 + .../006_equipment_categories.down.sql | 1 + .../006_equipment_categories.up.sql | 11 + hasura/migrations/007_users.down.sql | 2 + hasura/migrations/007_users.up.sql | 28 ++ hasura/migrations/008_equipment.down.sql | 2 + hasura/migrations/008_equipment.up.sql | 64 ++++ hasura/migrations/009_issues.down.sql | 3 + hasura/migrations/009_issues.up.sql | 84 +++++ .../010_maintenance_actions.down.sql | 1 + .../migrations/010_maintenance_actions.up.sql | 42 +++ hasura/migrations/011_parts_used.down.sql | 1 + hasura/migrations/011_parts_used.up.sql | 20 ++ .../migrations/012_preventive_tasks.down.sql | 1 + hasura/migrations/012_preventive_tasks.up.sql | 24 ++ .../013_preventive_schedules.down.sql | 1 + .../013_preventive_schedules.up.sql | 30 ++ .../014_equipment_analytics.down.sql | 1 + .../migrations/014_equipment_analytics.up.sql | 47 +++ .../migrations/015_notifications_log.down.sql | 1 + .../migrations/015_notifications_log.up.sql | 39 +++ .../016_industry_templates.down.sql | 1 + .../migrations/016_industry_templates.up.sql | 244 ++++++++++++++ scripts/init-progres.sql | 6 + 126 files changed, 2579 insertions(+) create mode 100644 api/.air.toml create mode 100644 api/Dockerfile create mode 100644 api/go.mod create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 front/Caddyfile create mode 100644 front/Dockerfile rename index.html => front/index.html (100%) rename package-lock.json => front/package-lock.json (100%) rename package.json => front/package.json (100%) rename postcss.config.js => front/postcss.config.js (100%) rename {public => front/public}/android-chrome-192x192.png (100%) rename {public => front/public}/android-chrome-512x512.png (100%) rename {public => front/public}/apple-touch-icon.png (100%) rename {public => front/public}/favicon-16x16.png (100%) rename {public => front/public}/favicon-32x32.png (100%) rename {public => front/public}/operafix_logo.png (100%) rename {src => front/src}/App.tsx (100%) rename {src => front/src}/components/Badge.tsx (100%) rename {src => front/src}/components/Button.tsx (100%) rename {src => front/src}/components/Card.tsx (100%) rename {src => front/src}/components/Input.tsx (100%) rename {src => front/src}/components/Layout.tsx (100%) rename {src => front/src}/components/PWAInstallPrompt.tsx (100%) rename {src => front/src}/components/StatsCard.tsx (100%) rename {src => front/src}/components/Table.tsx (100%) rename {src => front/src}/context/AuthContext.tsx (100%) rename {src => front/src}/context/ThemeContext.tsx (100%) rename {src => front/src}/context/ToastContext.tsx (100%) rename {src => front/src}/data/mockData.ts (100%) rename {src => front/src}/i18n.ts (100%) rename {src => front/src}/index.css (100%) rename {src => front/src}/lib/utils.ts (100%) rename {src => front/src}/locales/en-GB.json (100%) rename {src => front/src}/locales/pt-PT.json (100%) rename {src => front/src}/main.tsx (100%) rename {src => front/src}/pages/Analytics.tsx (100%) rename {src => front/src}/pages/Dashboard.tsx (100%) rename {src => front/src}/pages/Equipment/EquipmentInfo.tsx (100%) rename {src => front/src}/pages/Equipment/EquipmentList.tsx (100%) rename {src => front/src}/pages/Locations/LocationsList.tsx (100%) rename {src => front/src}/pages/Login.tsx (100%) rename {src => front/src}/pages/Preventive/PreventiveList.tsx (100%) rename {src => front/src}/pages/QRScanner.tsx (100%) rename {src => front/src}/pages/Reports/ReportCreation.tsx (100%) rename {src => front/src}/pages/Reports/ReportDetail.tsx (100%) rename {src => front/src}/pages/Reports/ReportsList.tsx (100%) rename {src => front/src}/pages/Settings.tsx (100%) rename {src => front/src}/pages/Technicians/TechnicianAssignment.tsx (100%) rename {src => front/src}/types/index.ts (100%) rename {src => front/src}/vite-env.d.ts (100%) rename tailwind.config.js => front/tailwind.config.js (100%) rename tsconfig.json => front/tsconfig.json (100%) rename vite.config.ts => front/vite.config.ts (100%) create mode 100644 hasura/Dockerfile create mode 100644 hasura/config.yaml create mode 100644 hasura/metadata/actions.graphql create mode 100644 hasura/metadata/actions.yaml create mode 100644 hasura/metadata/allow_list.yaml create mode 100644 hasura/metadata/api_limits.yaml create mode 100644 hasura/metadata/backend_configs.yaml create mode 100644 hasura/metadata/cron_triggers.yaml create mode 100644 hasura/metadata/databases/databases.yaml create mode 100644 hasura/metadata/databases/default/tables/public_companies.yaml create mode 100644 hasura/metadata/databases/default/tables/public_equipment.yaml create mode 100644 hasura/metadata/databases/default/tables/public_equipment_analytics.yaml create mode 100644 hasura/metadata/databases/default/tables/public_equipment_categories.yaml create mode 100644 hasura/metadata/databases/default/tables/public_equipment_photos.yaml create mode 100644 hasura/metadata/databases/default/tables/public_industry_templates.yaml create mode 100644 hasura/metadata/databases/default/tables/public_issue_categories.yaml create mode 100644 hasura/metadata/databases/default/tables/public_issue_comments.yaml create mode 100644 hasura/metadata/databases/default/tables/public_issue_photos.yaml create mode 100644 hasura/metadata/databases/default/tables/public_issues.yaml create mode 100644 hasura/metadata/databases/default/tables/public_location_types.yaml create mode 100644 hasura/metadata/databases/default/tables/public_locations.yaml create mode 100644 hasura/metadata/databases/default/tables/public_maintenance_actions.yaml create mode 100644 hasura/metadata/databases/default/tables/public_notifications_log.yaml create mode 100644 hasura/metadata/databases/default/tables/public_parts_used.yaml create mode 100644 hasura/metadata/databases/default/tables/public_preventive_schedules.yaml create mode 100644 hasura/metadata/databases/default/tables/public_preventive_tasks.yaml create mode 100644 hasura/metadata/databases/default/tables/public_severity_levels.yaml create mode 100644 hasura/metadata/databases/default/tables/public_users.yaml create mode 100644 hasura/metadata/databases/default/tables/tables.yaml create mode 100644 hasura/metadata/graphql_schema_introspection.yaml create mode 100644 hasura/metadata/inherited_roles.yaml create mode 100644 hasura/metadata/metrics_config.yaml create mode 100644 hasura/metadata/network.yaml create mode 100644 hasura/metadata/opentelemetry.yaml create mode 100644 hasura/metadata/query_collections.yaml create mode 100644 hasura/metadata/remote_schemas.yaml create mode 100644 hasura/metadata/rest_endpoints.yaml create mode 100644 hasura/metadata/version.yaml create mode 100644 hasura/migrations/001_companies.down.sql create mode 100644 hasura/migrations/001_companies.up.sql create mode 100644 hasura/migrations/002_location_types.down.sql create mode 100644 hasura/migrations/002_location_types.up.sql create mode 100644 hasura/migrations/003_locations.down.sql create mode 100644 hasura/migrations/003_locations.up.sql create mode 100644 hasura/migrations/004_severity_levels.down.sql create mode 100644 hasura/migrations/004_severity_levels.up.sql create mode 100644 hasura/migrations/005_issue_categories.down.sql create mode 100644 hasura/migrations/005_issue_categories.up.sql create mode 100644 hasura/migrations/006_equipment_categories.down.sql create mode 100644 hasura/migrations/006_equipment_categories.up.sql create mode 100644 hasura/migrations/007_users.down.sql create mode 100644 hasura/migrations/007_users.up.sql create mode 100644 hasura/migrations/008_equipment.down.sql create mode 100644 hasura/migrations/008_equipment.up.sql create mode 100644 hasura/migrations/009_issues.down.sql create mode 100644 hasura/migrations/009_issues.up.sql create mode 100644 hasura/migrations/010_maintenance_actions.down.sql create mode 100644 hasura/migrations/010_maintenance_actions.up.sql create mode 100644 hasura/migrations/011_parts_used.down.sql create mode 100644 hasura/migrations/011_parts_used.up.sql create mode 100644 hasura/migrations/012_preventive_tasks.down.sql create mode 100644 hasura/migrations/012_preventive_tasks.up.sql create mode 100644 hasura/migrations/013_preventive_schedules.down.sql create mode 100644 hasura/migrations/013_preventive_schedules.up.sql create mode 100644 hasura/migrations/014_equipment_analytics.down.sql create mode 100644 hasura/migrations/014_equipment_analytics.up.sql create mode 100644 hasura/migrations/015_notifications_log.down.sql create mode 100644 hasura/migrations/015_notifications_log.up.sql create mode 100644 hasura/migrations/016_industry_templates.down.sql create mode 100644 hasura/migrations/016_industry_templates.up.sql create mode 100644 scripts/init-progres.sql diff --git a/SYSTEM_DESIGN_README.md b/SYSTEM_DESIGN_README.md index 25e988b..d3027f7 100644 --- a/SYSTEM_DESIGN_README.md +++ b/SYSTEM_DESIGN_README.md @@ -570,6 +570,318 @@ 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 qr_code_id + 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. + +- `qr_code_id` is the physical-to-digital bridge. Format: EQ-00142. 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 diff --git a/api/.air.toml b/api/.air.toml new file mode 100644 index 0000000..4a069e3 --- /dev/null +++ b/api/.air.toml @@ -0,0 +1,26 @@ +# api/.air.toml — hot-reload config for Go service +# Paths are relative to the api/ directory (the build context and volume mount root) + +root = "." +tmp_dir = "/tmp/air" + +[build] + cmd = "go build -o /tmp/air/mantis-api ./cmd/server" + bin = "/tmp/air/mantis-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..dd7ea70 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,134 @@ +# Stages: +# base → shared foundation (deps downloaded, tools installed) +# development → hot-reload via `air` (source mounted as volume) +# builder → compiles the static production binary +# production → minimal scratch image, binary only + +# Shared foundation used by both development and builder stages. +# Installing deps here means they are layer-cached and not repeated +# in child stages — Docker only re-runs this if go.mod or go.sum change. +FROM golang:1.23-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 only the dependency manifests first. +# Docker layer caching: if go.mod and go.sum haven't changed, the next +# RUN (go mod download) is skipped entirely — saving 20-60s per build. +# COPY . . must come AFTER this, not before. +COPY go.mod go.sum ./ + +# Download all dependencies declared in go.mod into the module cache. +# `go mod verify` checks that downloaded files match their expected checksums. +# This catches any supply-chain tampering or corrupted downloads. +RUN go mod download && go mod verify + + +# Used by docker-compose.dev.yml. +# Does NOT copy source code — source is mounted as a live volume at +# runtime so that file changes are visible inside the container immediately. +# `air` watches the mounted source and rebuilds the binary on every save. +FROM base AS development + +# Install `air` — a file watcher that rebuilds and restarts the Go +# binary whenever a .go file changes. Equivalent to nodemon for Go. +# Pinned to a specific version for reproducibility. +# Config lives in api/.air.toml +RUN go install github.com/air-verse/air@v1.52.3 + +# The Go module cache from the base stage is available here. +# The source code is NOT copied — it arrives via the docker-compose volume: +# volumes: +# - ./api:/app:delegated +# This means any file save on your host triggers an automatic rebuild +# inside the container without restarting the container itself. + +EXPOSE 8080 + +# air reads its config from .air.toml in the working directory (/app), +# which is satisfied by the volume mount of ./api at runtime. +CMD ["air", "-c", ".air.toml"] + +# Compiles the production binary. This stage is never deployed — +# it exists only to produce the binary that the production stage copies. +# Running the compiler in golang:alpine means the production image +# does not need Go installed at all. +FROM base AS builder + +# Now copy the full source tree into the builder. +# This layer is invalidated whenever any source file changes, +# which is fine — the builder stage is only run for production builds. +COPY . . + +# Build metadata injected via ldflags — set by your CI/CD pipeline. +# Available in code via: var Version, Commit, BuildTime string (in main.go) +# Useful for /health or /version endpoints to confirm what is deployed. +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_TIME=unknown + +# Compile flags explained: +# CGO_ENABLED=0 — disable C bindings → pure Go binary with no +# libc dependency → runs on scratch (no libc present) +# GOOS=linux — always target Linux regardless of build machine OS +# GOARCH=amd64 — target 64-bit x86 (change to arm64 for Apple Silicon servers) +# -trimpath — strip local filesystem paths from the binary +# (prevents leaking your dev machine's directory structure +# in stack traces and error messages) +# -ldflags "-s -w" — -s strips the symbol table, -w strips DWARF debug info +# together they reduce binary size by ~30% +# -X — inject build metadata as string variables at link time +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. Built on `scratch` — an empty base image +# with literally nothing in it: no shell, no package manager, no OS utils. +# +# Attack surface: zero. If an attacker gets RCE they have no tools to +# work with — no bash, no curl, no wget, no package manager. +# Image size: ~15MB (binary + certs + timezone data only). +FROM scratch AS production + +# Copy TLS certificates from the builder stage. +# Without this, any HTTPS call (Postmark, Twilio, R2, Hasura over TLS) +# fails with "x509: certificate signed by unknown authority". +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +# Copy timezone database from the builder stage. +# Without this, time.LoadLocation("Europe/Lisbon") panics at runtime. +# All timestamp conversions for display (UTC → location timezone) depend on this. +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo + +# Copy only the compiled binary — nothing else from the builder stage. +COPY --from=builder /bin/operafix-api /operafix-api + +EXPOSE 8080 + +# Run as a non-root user. +# scratch has no /etc/passwd so we use a numeric UID directly. +# 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 vs CMD: +# ENTRYPOINT — the binary that always runs (cannot be overridden without --entrypoint) +# CMD — default arguments passed to ENTRYPOINT (can be overridden) +# Using ENTRYPOINT here means `docker run operafix-api` always runs the binary. +ENTRYPOINT ["/operafix-api"] diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 0000000..f2c328b --- /dev/null +++ b/api/go.mod @@ -0,0 +1,3 @@ +module api + +go 1.24.4 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..c32016e --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,310 @@ +# Boot order (enforced by depends_on + healthchecks): +# 1. postgres — database must be accepting connections +# 2. migrate — runs all SQL migrations, then exits +# 3. hasura — GraphQL engine starts after schema exists +# 4. 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: + # The only persistent data store. All tables live here. + # Data survives container restarts via the postgres_data volume. + postgres: + image: postgres:16-alpine + # Container name is what other services use to reach this host + container_name: operafix_postgres + # unless-stopped: restarts automatically if it crashes, + # but respects `docker compose stop` / `docker compose down` + 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/data + # 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 + # Mounted read-only (:ro) — this file should never be written at runtime + - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + ports: + - "5432:5432" + healthcheck: + # pg_isready polls until Postgres is actually accepting connections. + # Other services use `condition: service_healthy` to wait for this. + # Without healthchecks, migrate might run before Postgres is ready + # and fail with "connection refused". + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-operafix} -d ${POSTGRES_DB:-operafix}", + ] + # check every 5 seconds + interval: 5s + # fail the check if no response within 5s + timeout: 5s + # mark unhealthy after 10 consecutive failures + retries: 10 + networks: + - operafix_net + + # A one-shot service: runs all SQL migration files in order, then exits. + # It is NOT a long-running server + # + # Migration files live in hasura/migrations/ and are named: + # 001_companies.up.sql, 001_companies.down.sql + # 002_location_types.up.sql, ... + # + # golang-migrate reads the numeric prefix to determine order. + # It tracks which migrations have already run in a schema_migrations + # table inside Postgres, so re-running is safe (idempotent). + migrate: + image: migrate/migrate:v4.17.0 + container_name: operafix_migrate + # restart: "no" — this service is expected to exit after running. + # Docker will not try to restart it. If it fails (non-zero exit), + # docker compose will report the error and halt dependent services. + restart: "no" + depends_on: + postgres: + # Wait until postgres passes its healthcheck before starting. + # Without this, migrate would try to connect before Postgres is ready. + condition: service_healthy + volumes: + # Mount migration files into the container at /migrations. + # Read-only — migrate only reads these files, never writes them. + - ./hasura/migrations:/migrations:ro + command: + # -path: where to find the migration files inside the container + - "-path=/migrations" + # -database: full Postgres connection string + # Uses the same credentials as the postgres service above + - "-database=postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD:-operafix_dev_password}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=disable" + # up: apply all pending migrations not yet recorded in schema_migrations + - "up" + networks: + - operafix_net + + # Sits in front of Postgres and exposes an instant GraphQL API. + # Handles: queries, mutations, real-time subscriptions, row-level + # permissions, event triggers (calls Go API when DB rows change), + # and actions (proxies complex operations to the Go API). + # Dev: plain upstream image, metadata mounted as a volume so you + # can change permissions/relationships without rebuilding. + # Prod: `docker build --target production ./hasura` bakes metadata + # into the image — no volume mounts needed at runtime. + hasura: + build: + # Docker build context: the hasura/ directory + context: ./hasura + # hasura/Dockerfile + dockerfile: Dockerfile + # Use the development stage (plain upstream image) + target: development + container_name: operafix_hasura + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + migrate: + # Wait until all SQL migrations have run successfully. + # Hasura introspects the DB schema on boot — tables must exist first. + condition: service_completed_successfully + environment: + # Connection string Hasura uses to talk to Postgres. + # >- is YAML block scalar: joins lines into one string without newline. + # Required here 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} + # Admin secret: required to access the Hasura console and make + # schema/metadata changes. Also used by the Go API to make + # privileged queries that bypass row-level permissions. + # Generate a strong one with: openssl rand -hex 16 + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme_admin_secret_32chars} + # JWT secret: Hasura validates every incoming request's JWT against this. + # Must match JWT_SECRET in the api service — they share the same HS256 + # key so tokens issued by Go are accepted by Hasura automatically. + # The JSON wrapper format is required by Hasura (not a plain string). + HASURA_GRAPHQL_JWT_SECRET: >- + {"type":"HS256","key":"${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long}"} + # Console: browser UI at http://localhost:8080/console + # Disable in production — it's a dev/admin tool only. + HASURA_GRAPHQL_ENABLE_CONSOLE: "true" + # Dev mode: adds detailed error messages to GraphQL responses. + # Never expose these to end users — disable in production. + HASURA_GRAPHQL_DEV_MODE: "true" + # Log types emitted to stdout. In production remove query-log + # (very verbose) and keep startup + http-log only. + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log,websocket-log,query-log + # Directory Hasura reads metadata from on startup. + # Must match the volume mount target path below. + HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata + # Base URL for Hasura Actions and Event Triggers. + # 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. + # Keep low locally. Raise for production based on observed load. + HASURA_GRAPHQL_PG_CONNECTIONS: "10" + # Transaction isolation level for all Hasura DB operations. + HASURA_GRAPHQL_TX_ISOLATION: serializable + volumes: + # Mount local metadata into the container so changes (permissions, + # relationships, actions) apply on restart without rebuilding. + # In production this volume is absent — metadata is baked into the image. + - ./hasura/metadata:/hasura-metadata:ro + ports: + # Hasura console + GraphQL API at http://localhost:8080 + # frontend talks to /v1/graphql on this port. + - "8080:8080" + healthcheck: + # /healthz returns 200 only when Hasura is fully booted + # and has successfully connected to Postgres. + 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 + + # # Handles everything Hasura cannot do directly: + # # /auth — JWT issuance, refresh tokens, magic-link email login + # # /upload — presigned Cloudflare R2 URLs for photo uploads + # # /qr — QR code + printable label PDF generation + # # /onboard — industry template seeding on company signup + # # /notify — email (Postmark) + SMS (Twilio) dispatch + # # /cron — SLA monitor, PM scheduler, nightly analytics aggregation + # # /hasura — Action + Event Trigger webhook handlers + # # + # # In development, `air` watches for .go file changes and rebuilds + # # the binary automatically — no need to restart the container. + # 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. + # # It makes privileged GraphQL calls to Hasura during onboarding + # # and would fail immediately if Hasura isn't accepting requests. + # condition: service_healthy + # environment: + # # Direct Postgres connection string for the Go service. + # # Used for operations that bypass Hasura: auth queries, + # # cron jobs writing analytics data, migrations during tests. + # 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" resolves to the hasura container on the Docker network. + # HASURA_ENDPOINT: http://hasura:8080/v1/graphql + # HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme_admin_secret_32chars} + # # JWT signing key — must be identical to Hasura's JWT secret above. + # # Go signs tokens with this key; Hasura verifies them with the same key. + # JWT_SECRET: ${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long} + # # 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 + # # debug | info | warn | error + # 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 + # # Named volume for the Go module cache ($GOPATH/pkg/mod). + # # Avoids re-downloading all dependencies on every `docker compose build`. + # - go_mod_cache:/root/go/pkg/mod + # ports: + # # (8080 is already used by Hasura, so API uses 8081 on the host) + # - "8081:8080" + # networks: + # - operafix_net + + # Serves the React app with Vite's dev server in development. + # HMR (Hot Module Replacement) means the browser updates instantly + # on file save — no full page reload. + # + # In production: `docker build --target production ./front` compiles + # static assets and serves them via Caddy. + front: + build: + context: ./front + dockerfile: Dockerfile + # Runs Vite dev server with HMR + target: development + container_name: operafix_front + restart: unless-stopped + depends_on: + hasura: + # Frontend needs Hasura ready before it can serve meaningful content. + # Without this the app boots but every GraphQL query fails on load. + condition: service_healthy + environment: + # VITE_* prefix is mandatory — Vite only injects variables with this + # prefix into the browser bundle. Unprefixed vars stay server-side. + + # HTTP endpoint for GraphQL queries and mutations + VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL:-http://localhost:8080/v1/graphql} + # WebSocket endpoint for real-time subscriptions + # (e.g. manager dashboard updating live when issue status changes) + VITE_GRAPHQL_WS: ${VITE_GRAPHQL_WS:-ws://localhost:8080/v1/graphql} + # Go API base URL for auth, file uploads, QR generation + VITE_API_URL: ${VITE_API_URL:-http://localhost:8081} + volumes: + # Mount front/ source so Vite's file watcher picks up changes. + - ./front:/app:delegated + # Named volume for node_modules — essential on macOS and Windows. + # Without this, Docker mounts your host's node_modules (compiled for + # your OS) into the Linux container, breaking native binary addons. + # The named volume holds a Linux-native copy built inside the container. + - front_node_modules:/app/node_modules + ports: + - "5173:5173" + networks: + - operafix_net + +volumes: + # Your entire database. Deleting this = dropping all tables and data. + postgres_data: + # Go module cache. Safe to delete — re-downloaded on next build. + go_mod_cache: + # Linux-native node_modules for the frontend. Safe to delete — + # npm ci reinstalls on next build (takes ~30s). + front_node_modules: +# Networks +# All services share one bridge network so they can reach each other +# by service name (postgres, hasura, api, front). +# Nothing on this network is externally reachable unless explicitly +# mapped via `ports:` above. +networks: + operafix_net: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bfbf8ac --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,226 @@ +# Usage: +# docker compose -f docker-compose.prod.yml up -d +# +# Before running: +# 1. All VITE_* vars must be set — they are baked into the JS +# bundle at build time. No fallback defaults on purpose: +# Docker will refuse to start if they are missing. +# 2. All secrets must be set in your server .env or secrets manager. +# Never use the changeme_* placeholders in production. +# 3. Generate strong secrets: +# openssl rand -hex 32 ← for JWT_SECRET +# openssl rand -hex 16 ← for HASURA_ADMIN_SECRET +# +# Key differences from docker-compose.dev.yml: +# - All services build their production stage (compiled binaries, +# static assets — no hot-reload tooling) +# - Hasura console and dev mode are disabled +# - Postgres is not exposed on the host — internal only +# - No source code volume mounts +# - No go_mod_cache or front_node_modules volumes +# - Log level is info, not debug +# - Resource limits set on every service +# - Caddy serves the frontend on port 80 +services: + postgres: + image: postgres:16-alpine + container_name: operafix_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-operafix} + POSTGRES_USER: ${POSTGRES_USER:-operafix} + # no fallback — must be set + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + # No `ports:` — Postgres is internal only in production. + # Exposing 5432 to the internet is a critical security risk. + 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 + + # Runs pending migrations on every deploy, then exits. + # Safe to run repeatedly — already-applied migrations are skipped. + migrate: + image: migrate/migrate:v4.17.0 + container_name: operafix_migrate + restart: "no" + depends_on: + postgres: + condition: service_healthy + volumes: + - ./hasura/migrations:/migrations:ro + command: + - "-path=/migrations" + - "-database=postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=require" + - "up" + networks: + - operafix_net + + # Production image has metadata baked in (no volume mount). + # Console and dev mode are disabled. + hasura: + build: + context: ./hasura + dockerfile: Dockerfile + # metadata copied into the image at build time + target: production + container_name: operafix_hasura + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + HASURA_GRAPHQL_DATABASE_URL: >- + postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix} + # no fallback — must be set + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} + + HASURA_GRAPHQL_JWT_SECRET: >- + {"type":"HS256","key":"${JWT_SECRET}"} # no fallback — must be set + # Disabled in production — exposes schema and admin API + HASURA_GRAPHQL_ENABLE_CONSOLE: "false" + # Disabled in production — leaks error internals to clients + HASURA_GRAPHQL_DEV_MODE: "false" + # query-log and websocket-log removed — too verbose for production + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log + # Metadata is baked into the production image — no mount needed + HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata + ACTION_BASE_URL: ${ACTION_BASE_URL:-http://api:8080} + # Raise connection pool for production load + HASURA_GRAPHQL_PG_CONNECTIONS: "25" + HASURA_GRAPHQL_TX_ISOLATION: serializable + # No volume mount — metadata ships inside the production image + ports: + # Only expose internally unless you're putting Hasura behind + # a reverse proxy. If using Caddy/Traefik in front, remove this. + - "8080: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 + + # Production stage: static binary in a scratch image. + # No shell, no package manager, ~10MB image size. + api: + build: + context: ./api + dockerfile: Dockerfile + target: production + args: + # Injected into the binary via ldflags — set by your CI/CD pipeline + 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=require + HASURA_ENDPOINT: http://hasura:8080/v1/graphql + # no fallback + HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} + # no fallback + 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 volume mounts — production runs the compiled binary only + ports: + - "8081:8080" + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M + networks: + - operafix_net + + # Production stage: npm run build → static assets served by Caddy. + # Caddy handles: gzip/zstd compression, aggressive asset caching, + # SPA routing (try_files → index.html), security headers. + front: + build: + context: ./front + dockerfile: Dockerfile + target: production + args: + # VITE_* vars are baked into the JS bundle at build time. + # These must be your real public-facing URLs — not localhost, + # not internal Docker hostnames. No fallbacks intentionally: + # Docker refuses to build if these are unset. + 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 + # No environment vars at runtime — everything was baked in at build time + # No volume mounts — Caddy serves from /srv inside the image + ports: + # Caddy serves on port 80 inside the container. + # Put your hosting platform's reverse proxy or load balancer in front. + - "80:80" + deploy: + resources: + limits: + memory: 128M + reservations: + memory: 64M + networks: + - operafix_net + +# Only postgres_data in production. +# go_mod_cache and front_node_modules are dev-only build artefacts. +volumes: + postgres_data: +networks: + operafix_net: + driver: bridge diff --git a/front/Caddyfile b/front/Caddyfile new file mode 100644 index 0000000..ba61092 --- /dev/null +++ b/front/Caddyfile @@ -0,0 +1,36 @@ +:80 { + root * /srv + + # Encode responses — Caddy handles gzip and zstd automatically + encode zstd gzip + + # SPA fallback — any path that isn't a real file serves index.html + # so React Router can handle the route client-side + try_files {path} /index.html + + file_server + + # ── Cache headers ────────────────────────────────────────────── + # Vite fingerprints assets (main.a1b2c3.js) — safe to cache forever + @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" + + # index.html must never be cached — it bootstraps the SPA + @html { + path *.html + } + header @html Cache-Control "no-store" + + # ── Security headers ─────────────────────────────────────────── + header { + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin" + X-XSS-Protection "1; mode=block" + # Remove Caddy's Server header + -Server + } +} + diff --git a/front/Dockerfile b/front/Dockerfile new file mode 100644 index 0000000..8eaadfe --- /dev/null +++ b/front/Dockerfile @@ -0,0 +1,129 @@ +# 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 + +# Shared foundation — installs node_modules once, cached for child stages. +# Using the Alpine variant keeps the image small (~170MB vs ~1GB for full node). +FROM node:20-alpine AS base + +WORKDIR /app + +# Copy manifests before source code. +# Docker layer caching: if package.json and package-lock.json haven't +# changed, the npm ci layer is reused — saving 30-120s per build. +# COPY . . must come AFTER this block in the builder stage. +COPY package.json package-lock.json ./ + +# npm ci (clean install) vs npm install: +# npm ci — installs exactly what package-lock.json specifies. +# Fails if lock file is out of sync with package.json. +# Faster and reproducible — the right choice for Docker. +# npm install — resolves and may update versions, not reproducible. +# +# --prefer-offline: use the npm cache before hitting the registry. +# Speeds up builds when the same packages were previously downloaded. +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 +# +# The second volume (front_node_modules) is critical on macOS/Windows: +# it shadows the host's node_modules with a Linux-native copy built +# inside this image layer. Without it, native binary addons compiled +# for macOS would be mounted into the Linux container and crash. +FROM base AS development + +# node_modules from the base stage are already at /app/node_modules. +# The source code arrives via the docker-compose volume mount at runtime. + +EXPOSE 5173 + +# --host 0.0.0.0 — Vite binds to all network interfaces inside the +# container, not just 127.0.0.1. Required for Docker's port forwarding +# to work: without this, Vite listens only on the container's loopback +# and the mapped port 5173 on your host receives no traffic. +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + + +# ── Stage 3: builder ──────────────────────────────────────────────── +# Compiles the React app into optimised static assets. +# Output goes to /app/dist — copied into the production stage. +FROM base AS builder + +# Copy the full source tree into the builder. +# node_modules are already present from the base stage. +COPY . . + +# VITE_* environment variables are baked into the JavaScript bundle +# at build time by Vite's define plugin. They are NOT available at +# runtime — Caddy serves static files and has no concept of env vars. +# +# These must be your real public-facing URLs (not localhost or internal +# Docker hostnames). They are injected via docker-compose build args: +# args: +# VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL} +# +# No default values — if unset, the build fails loudly rather than +# silently baking "undefined" into the bundle. +ARG VITE_GRAPHQL_URL +ARG VITE_GRAPHQL_WS +ARG VITE_API_URL + +# Promote build args to environment variables so Vite can read them. +# ARG values are not automatically visible as ENV — this step is required. +ENV VITE_GRAPHQL_URL=$VITE_GRAPHQL_URL \ + VITE_GRAPHQL_WS=$VITE_GRAPHQL_WS \ + VITE_API_URL=$VITE_API_URL + +# Run the Vite production build. +# Output: /app/dist/ +# index.html — entry point (never cached) +# assets/index.abc123.js — fingerprinted JS bundle (cached 1 year) +# assets/index.abc123.css — fingerprinted CSS (cached 1 year) +# + any other static assets from /public +RUN npm run build + + +# ── Stage 4: production ───────────────────────────────────────────── +# Serves the compiled static assets using Caddy. +# Caddy handles: gzip/zstd compression, cache headers, SPA routing, +# and security headers — all configured in the Caddyfile. +# +# Why Caddy over nginx: +# - Single-line compression (zstd + gzip) vs nginx's gzip_* block +# - Correct mime types out of the box (no mime.types file needed) +# - Simpler SPA routing config +# - Strips Server header by default +FROM caddy:2.8-alpine AS production + +# Copy the Caddyfile from the build context (front/Caddyfile). +# This configures: +# - SPA fallback (try_files → index.html for React Router) +# - Aggressive caching on fingerprinted assets (1 year, immutable) +# - No-cache on index.html (entry point must always be fresh) +# - Security headers (X-Frame-Options, X-Content-Type-Options, etc.) +# - gzip + zstd compression +COPY Caddyfile /etc/caddy/Caddyfile + +# Copy the compiled static assets from the builder stage. +# Caddy is configured to serve from /srv — this is Caddy's conventional +# static file root (matches the default Caddy image expectation). +# Only the dist/ contents are copied — no node_modules, no source files. +COPY --from=builder /app/dist /srv + +# Caddy listens on port 80 inside the container. +# Mapped to host port 80 in docker-compose.prod.yml. +EXPOSE 80 + +# Caddy runs as root inside the container by default. +# This is acceptable for a static file server with no write access to +# the filesystem — Caddy drops privileges after binding to port 80. +# If your platform requires non-root, use caddy:2.8-alpine with +# `USER caddy` and switch to port 8080 (no privilege needed above 1024). +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/index.html b/front/index.html similarity index 100% rename from index.html rename to front/index.html 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 100% rename from src/App.tsx rename to front/src/App.tsx diff --git a/src/components/Badge.tsx b/front/src/components/Badge.tsx similarity index 100% rename from src/components/Badge.tsx rename to front/src/components/Badge.tsx diff --git a/src/components/Button.tsx b/front/src/components/Button.tsx similarity index 100% rename from src/components/Button.tsx rename to front/src/components/Button.tsx diff --git a/src/components/Card.tsx b/front/src/components/Card.tsx similarity index 100% rename from src/components/Card.tsx rename to front/src/components/Card.tsx diff --git a/src/components/Input.tsx b/front/src/components/Input.tsx similarity index 100% rename from src/components/Input.tsx rename to front/src/components/Input.tsx diff --git a/src/components/Layout.tsx b/front/src/components/Layout.tsx similarity index 100% rename from src/components/Layout.tsx rename to front/src/components/Layout.tsx diff --git a/src/components/PWAInstallPrompt.tsx b/front/src/components/PWAInstallPrompt.tsx similarity index 100% rename from src/components/PWAInstallPrompt.tsx rename to front/src/components/PWAInstallPrompt.tsx diff --git a/src/components/StatsCard.tsx b/front/src/components/StatsCard.tsx similarity index 100% rename from src/components/StatsCard.tsx rename to front/src/components/StatsCard.tsx diff --git a/src/components/Table.tsx b/front/src/components/Table.tsx similarity index 100% rename from src/components/Table.tsx rename to front/src/components/Table.tsx diff --git a/src/context/AuthContext.tsx b/front/src/context/AuthContext.tsx similarity index 100% rename from src/context/AuthContext.tsx rename to front/src/context/AuthContext.tsx diff --git a/src/context/ThemeContext.tsx b/front/src/context/ThemeContext.tsx similarity index 100% rename from src/context/ThemeContext.tsx rename to front/src/context/ThemeContext.tsx diff --git a/src/context/ToastContext.tsx b/front/src/context/ToastContext.tsx similarity index 100% rename from src/context/ToastContext.tsx rename to front/src/context/ToastContext.tsx diff --git a/src/data/mockData.ts b/front/src/data/mockData.ts similarity index 100% rename from src/data/mockData.ts rename to front/src/data/mockData.ts diff --git a/src/i18n.ts b/front/src/i18n.ts similarity index 100% rename from src/i18n.ts rename to front/src/i18n.ts diff --git a/src/index.css b/front/src/index.css similarity index 100% rename from src/index.css rename to front/src/index.css diff --git a/src/lib/utils.ts b/front/src/lib/utils.ts similarity index 100% rename from src/lib/utils.ts rename to front/src/lib/utils.ts diff --git a/src/locales/en-GB.json b/front/src/locales/en-GB.json similarity index 100% rename from src/locales/en-GB.json rename to front/src/locales/en-GB.json diff --git a/src/locales/pt-PT.json b/front/src/locales/pt-PT.json similarity index 100% rename from src/locales/pt-PT.json rename to front/src/locales/pt-PT.json diff --git a/src/main.tsx b/front/src/main.tsx similarity index 100% rename from src/main.tsx rename to front/src/main.tsx diff --git a/src/pages/Analytics.tsx b/front/src/pages/Analytics.tsx similarity index 100% rename from src/pages/Analytics.tsx rename to front/src/pages/Analytics.tsx diff --git a/src/pages/Dashboard.tsx b/front/src/pages/Dashboard.tsx similarity index 100% rename from src/pages/Dashboard.tsx rename to front/src/pages/Dashboard.tsx diff --git a/src/pages/Equipment/EquipmentInfo.tsx b/front/src/pages/Equipment/EquipmentInfo.tsx similarity index 100% rename from src/pages/Equipment/EquipmentInfo.tsx rename to front/src/pages/Equipment/EquipmentInfo.tsx diff --git a/src/pages/Equipment/EquipmentList.tsx b/front/src/pages/Equipment/EquipmentList.tsx similarity index 100% rename from src/pages/Equipment/EquipmentList.tsx rename to front/src/pages/Equipment/EquipmentList.tsx diff --git a/src/pages/Locations/LocationsList.tsx b/front/src/pages/Locations/LocationsList.tsx similarity index 100% rename from src/pages/Locations/LocationsList.tsx rename to front/src/pages/Locations/LocationsList.tsx diff --git a/src/pages/Login.tsx b/front/src/pages/Login.tsx similarity index 100% rename from src/pages/Login.tsx rename to front/src/pages/Login.tsx diff --git a/src/pages/Preventive/PreventiveList.tsx b/front/src/pages/Preventive/PreventiveList.tsx similarity index 100% rename from src/pages/Preventive/PreventiveList.tsx rename to front/src/pages/Preventive/PreventiveList.tsx diff --git a/src/pages/QRScanner.tsx b/front/src/pages/QRScanner.tsx similarity index 100% rename from src/pages/QRScanner.tsx rename to front/src/pages/QRScanner.tsx diff --git a/src/pages/Reports/ReportCreation.tsx b/front/src/pages/Reports/ReportCreation.tsx similarity index 100% rename from src/pages/Reports/ReportCreation.tsx rename to front/src/pages/Reports/ReportCreation.tsx diff --git a/src/pages/Reports/ReportDetail.tsx b/front/src/pages/Reports/ReportDetail.tsx similarity index 100% rename from src/pages/Reports/ReportDetail.tsx rename to front/src/pages/Reports/ReportDetail.tsx diff --git a/src/pages/Reports/ReportsList.tsx b/front/src/pages/Reports/ReportsList.tsx similarity index 100% rename from src/pages/Reports/ReportsList.tsx rename to front/src/pages/Reports/ReportsList.tsx diff --git a/src/pages/Settings.tsx b/front/src/pages/Settings.tsx similarity index 100% rename from src/pages/Settings.tsx rename to front/src/pages/Settings.tsx diff --git a/src/pages/Technicians/TechnicianAssignment.tsx b/front/src/pages/Technicians/TechnicianAssignment.tsx similarity index 100% rename from src/pages/Technicians/TechnicianAssignment.tsx rename to front/src/pages/Technicians/TechnicianAssignment.tsx diff --git a/src/types/index.ts b/front/src/types/index.ts similarity index 100% rename from src/types/index.ts rename to front/src/types/index.ts diff --git a/src/vite-env.d.ts b/front/src/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to front/src/vite-env.d.ts diff --git a/tailwind.config.js b/front/tailwind.config.js similarity index 100% rename from tailwind.config.js rename to front/tailwind.config.js diff --git a/tsconfig.json b/front/tsconfig.json similarity index 100% rename from tsconfig.json rename to front/tsconfig.json diff --git a/vite.config.ts b/front/vite.config.ts similarity index 100% rename from vite.config.ts rename to front/vite.config.ts diff --git a/hasura/Dockerfile b/hasura/Dockerfile new file mode 100644 index 0000000..6b1a331 --- /dev/null +++ b/hasura/Dockerfile @@ -0,0 +1,111 @@ +# Stages: +# development → plain upstream image, config via environment variables, +# metadata mounted as a volume at runtime +# production → same upstream image with metadata baked in, +# fully self-contained — no volume mounts at runtime +# +# Responsibility boundary: +# Hasura owns: metadata (permissions, relationships, actions, +# event triggers, remote schemas) +# Hasura does NOT own: database schema +# +# Database schema is managed by golang-migrate (hasura/migrations/). +# Migrations run before Hasura starts — Hasura introspects the existing +# tables and applies metadata on top. This separation means schema +# changes go through SQL files with proper up/down rollbacks, not +# through Hasura's console. +# +# Usage: +# Dev: docker compose -f docker-compose.dev.yml up hasura +# (builds the development stage — effectively the plain image) +# Prod: docker compose -f docker-compose.prod.yml up hasura +# (builds the production stage — metadata baked in) +# +# Metadata workflow: +# 1. Make changes in the Hasura console (http://localhost:8080) +# 2. Export: hasura metadata export \ +# --endpoint http://localhost:8080 \ +# --admin-secret +# 3. Commit the updated hasura/metadata/ directory +# 4. On next prod deploy, `docker build --target production ./hasura` +# bakes the new metadata into the image automatically +# ═══════════════════════════════════════════════════════════════════ + + +# Uses the plain upstream Hasura image with no modifications. +# +# All configuration is injected via environment variables at runtime +# (see docker-compose.dev.yml — the `hasura` service environment block). +# +# Metadata is NOT baked in — it is mounted as a read-only volume: +# volumes: +# - ./hasura/metadata:/hasura-metadata:ro +# +# This means you can edit permissions, relationships, and actions in +# the Hasura console and export them to your local hasura/metadata/ +# directory without rebuilding the image. Changes are picked up on +# the next container restart. +# +# Why a Dockerfile for development if the image is unchanged? +# Consistency — all four services (postgres excepted) define both +# a development and production stage. docker-compose.dev.yml always +# uses `target: development`. This makes the pattern uniform and +# means `docker build --target development ./hasura` always works. +FROM hasura/graphql-engine:v2.40.0 AS development + +# No additional layers — the upstream image is used as-is. +# Environment variables and volume mounts handle everything at runtime. + +EXPOSE 8080 + +# Same upstream image, but with metadata copied directly into the image. +# +# Why bake metadata into the image? +# In production there are no volume mounts. The container must be +# fully self-contained — deploy the image and it works with no +# external files required. This also means every production deployment +# is reproducible: the exact metadata that was tested is the exact +# metadata that ships. +# +# What is metadata? +# Everything Hasura knows about your data that is NOT the SQL schema: +# - Which tables are tracked (visible via GraphQL) +# - Relationships between tables (how joins are exposed) +# - Permissions (what each role can query/mutate) +# - Actions (HTTP endpoints proxied to the Go API) +# - Event triggers (Go API called when DB rows change) +# - Remote schemas (if any external GraphQL APIs are stitched in) +# +# What metadata does NOT include: +# - SQL schema (tables, columns, indexes, FKs) — that is in migrations/ +# - Data (rows) — that is seeded separately +FROM hasura/graphql-engine:v2.40.0 AS production + +# Copy the entire metadata directory from the build context (hasura/). +# Build context is ./hasura so this copies hasura/metadata/ → /hasura-metadata/ +# +# Contents after a `hasura metadata export`: +# /hasura-metadata/ +# databases/ +# databases.yaml — database connection config +# default/ +# tables/ +# tables.yaml — tracked tables + relationships + permissions +# actions.yaml — custom action definitions +# actions.graphql — GraphQL types for actions +# cron_triggers.yaml — scheduled triggers (if any) +# remote_schemas.yaml — stitched remote GraphQL APIs (if any) +# version.yaml — metadata format version +COPY metadata/ /hasura-metadata/ + +# Tell Hasura where to find the metadata on startup. +# Must match the directory we copied to above. +# Hasura applies this metadata automatically when the container starts — +# no manual `hasura metadata apply` step required. +ENV HASURA_GRAPHQL_METADATA_DIR=/hasura-metadata + +EXPOSE 8080 + +# No CMD — Hasura's upstream image already defines the correct entrypoint. +# The graphql-engine binary starts automatically with the env vars +# injected by docker-compose.prod.yml. diff --git a/hasura/config.yaml b/hasura/config.yaml new file mode 100644 index 0000000..803cb37 --- /dev/null +++ b/hasura/config.yaml @@ -0,0 +1,30 @@ +version: 3 + +# The running Hasura instance the CLI talks to. +# For local dev this is the container exposed on port 8080. +endpoint: http://localhost:8080 + +# Must match HASURA_GRAPHQL_ADMIN_SECRET in docker-compose.dev.yml. +# The CLI sends this on every request to authenticate. +# +# SECURITY: do not commit a real production secret here. +# For production use the --admin-secret flag or HASURA_GRAPHQL_ADMIN_SECRET env var: +# hasura console --admin-secret $HASURA_ADMIN_SECRET +admin_secret: changeme_admin_secret_32chars + +# Where the CLI reads/writes metadata files. +# Matches HASURA_GRAPHQL_METADATA_DIR inside the container. +metadata_directory: metadata + +# Where the CLI reads/writes migration files. +migrations_directory: migrations + +actions: + # synchronous: the Action HTTP call blocks until the Go API responds. + # Use asynchronous only for long-running background operations. + kind: synchronous + + # Base URL the Hasura console uses to generate Action handler stubs. + # In dev this points to the Go API running locally on port 8081. + # This is only used by the console for code generation — not at runtime. + handler_webhook_baseurl: http://localhost:8081 diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql new file mode 100644 index 0000000..e69de29 diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml new file mode 100644 index 0000000..1edb4c2 --- /dev/null +++ b/hasura/metadata/actions.yaml @@ -0,0 +1,6 @@ +actions: [] +custom_types: + enums: [] + input_objects: [] + objects: [] + scalars: [] diff --git a/hasura/metadata/allow_list.yaml b/hasura/metadata/allow_list.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/allow_list.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/api_limits.yaml b/hasura/metadata/api_limits.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/api_limits.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/backend_configs.yaml b/hasura/metadata/backend_configs.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/backend_configs.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/cron_triggers.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/databases/databases.yaml b/hasura/metadata/databases/databases.yaml new file mode 100644 index 0000000..6e9ab14 --- /dev/null +++ b/hasura/metadata/databases/databases.yaml @@ -0,0 +1,14 @@ +- name: default + kind: postgres + configuration: + connection_info: + database_url: + from_env: HASURA_GRAPHQL_DATABASE_URL + isolation_level: serializable + pool_settings: + connection_lifetime: 600 + idle_timeout: 180 + max_connections: 10 + retries: 1 + use_prepared_statements: true + tables: "!include default/tables/tables.yaml" diff --git a/hasura/metadata/databases/default/tables/public_companies.yaml b/hasura/metadata/databases/default/tables/public_companies.yaml new file mode 100644 index 0000000..e3fd4ce --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_companies.yaml @@ -0,0 +1,74 @@ +table: + name: companies + schema: public +array_relationships: + - name: equipment + using: + foreign_key_constraint_on: + column: company_id + table: + name: equipment + schema: public + - name: equipment_categories + using: + foreign_key_constraint_on: + column: company_id + table: + name: equipment_categories + schema: public + - name: issue_categories + using: + foreign_key_constraint_on: + column: company_id + table: + name: issue_categories + schema: public + - name: issues + using: + foreign_key_constraint_on: + column: company_id + table: + name: issues + schema: public + - name: location_types + using: + foreign_key_constraint_on: + column: company_id + table: + name: location_types + schema: public + - name: locations + using: + foreign_key_constraint_on: + column: company_id + table: + name: locations + schema: public + - name: notifications_logs + using: + foreign_key_constraint_on: + column: company_id + table: + name: notifications_log + schema: public + - name: preventive_tasks + using: + foreign_key_constraint_on: + column: company_id + table: + name: preventive_tasks + schema: public + - name: severity_levels + using: + foreign_key_constraint_on: + column: company_id + table: + name: severity_levels + schema: public + - name: users + using: + foreign_key_constraint_on: + column: company_id + table: + name: users + schema: public diff --git a/hasura/metadata/databases/default/tables/public_equipment.yaml b/hasura/metadata/databases/default/tables/public_equipment.yaml new file mode 100644 index 0000000..153094f --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_equipment.yaml @@ -0,0 +1,52 @@ +table: + name: equipment + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: equipment + using: + foreign_key_constraint_on: parent_equipment_id + - name: equipment_category + using: + foreign_key_constraint_on: category_id + - name: location + using: + foreign_key_constraint_on: location_id +array_relationships: + - name: equipmentByParentEquipmentId + using: + foreign_key_constraint_on: + column: parent_equipment_id + table: + name: equipment + schema: public + - name: equipment_analytics + using: + foreign_key_constraint_on: + column: equipment_id + table: + name: equipment_analytics + schema: public + - name: equipment_photos + using: + foreign_key_constraint_on: + column: equipment_id + table: + name: equipment_photos + schema: public + - name: issues + using: + foreign_key_constraint_on: + column: equipment_id + table: + name: issues + schema: public + - name: preventive_tasks + using: + foreign_key_constraint_on: + column: equipment_id + table: + name: preventive_tasks + schema: public diff --git a/hasura/metadata/databases/default/tables/public_equipment_analytics.yaml b/hasura/metadata/databases/default/tables/public_equipment_analytics.yaml new file mode 100644 index 0000000..5993e9d --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_equipment_analytics.yaml @@ -0,0 +1,7 @@ +table: + name: equipment_analytics + schema: public +object_relationships: + - name: equipment + using: + foreign_key_constraint_on: equipment_id diff --git a/hasura/metadata/databases/default/tables/public_equipment_categories.yaml b/hasura/metadata/databases/default/tables/public_equipment_categories.yaml new file mode 100644 index 0000000..084e482 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_equipment_categories.yaml @@ -0,0 +1,15 @@ +table: + name: equipment_categories + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id +array_relationships: + - name: equipment + using: + foreign_key_constraint_on: + column: category_id + table: + name: equipment + schema: public diff --git a/hasura/metadata/databases/default/tables/public_equipment_photos.yaml b/hasura/metadata/databases/default/tables/public_equipment_photos.yaml new file mode 100644 index 0000000..9bc2863 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_equipment_photos.yaml @@ -0,0 +1,7 @@ +table: + name: equipment_photos + schema: public +object_relationships: + - name: equipment + using: + foreign_key_constraint_on: equipment_id diff --git a/hasura/metadata/databases/default/tables/public_industry_templates.yaml b/hasura/metadata/databases/default/tables/public_industry_templates.yaml new file mode 100644 index 0000000..3530916 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_industry_templates.yaml @@ -0,0 +1,3 @@ +table: + name: industry_templates + schema: public diff --git a/hasura/metadata/databases/default/tables/public_issue_categories.yaml b/hasura/metadata/databases/default/tables/public_issue_categories.yaml new file mode 100644 index 0000000..e975532 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_issue_categories.yaml @@ -0,0 +1,18 @@ +table: + name: issue_categories + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: severity_level + using: + foreign_key_constraint_on: default_severity_id +array_relationships: + - name: issues + using: + foreign_key_constraint_on: + column: category_id + table: + name: issues + schema: public diff --git a/hasura/metadata/databases/default/tables/public_issue_comments.yaml b/hasura/metadata/databases/default/tables/public_issue_comments.yaml new file mode 100644 index 0000000..8277590 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_issue_comments.yaml @@ -0,0 +1,10 @@ +table: + name: issue_comments + schema: public +object_relationships: + - name: issue + using: + foreign_key_constraint_on: issue_id + - name: user + using: + foreign_key_constraint_on: author_id diff --git a/hasura/metadata/databases/default/tables/public_issue_photos.yaml b/hasura/metadata/databases/default/tables/public_issue_photos.yaml new file mode 100644 index 0000000..edac81e --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_issue_photos.yaml @@ -0,0 +1,10 @@ +table: + name: issue_photos + schema: public +object_relationships: + - name: issue + using: + foreign_key_constraint_on: issue_id + - name: user + using: + foreign_key_constraint_on: uploaded_by diff --git a/hasura/metadata/databases/default/tables/public_issues.yaml b/hasura/metadata/databases/default/tables/public_issues.yaml new file mode 100644 index 0000000..3682510 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_issues.yaml @@ -0,0 +1,54 @@ +table: + name: issues + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: equipment + using: + foreign_key_constraint_on: equipment_id + - name: issue_category + using: + foreign_key_constraint_on: category_id + - name: location + using: + foreign_key_constraint_on: location_id + - name: severity_level + using: + foreign_key_constraint_on: severity_id + - name: user + using: + foreign_key_constraint_on: assigned_to + - name: userByReporterId + using: + foreign_key_constraint_on: reporter_id +array_relationships: + - name: issue_comments + using: + foreign_key_constraint_on: + column: issue_id + table: + name: issue_comments + schema: public + - name: issue_photos + using: + foreign_key_constraint_on: + column: issue_id + table: + name: issue_photos + schema: public + - name: maintenance_actions + using: + foreign_key_constraint_on: + column: issue_id + table: + name: maintenance_actions + schema: public + - name: notifications_logs + using: + foreign_key_constraint_on: + column: issue_id + table: + name: notifications_log + schema: public diff --git a/hasura/metadata/databases/default/tables/public_location_types.yaml b/hasura/metadata/databases/default/tables/public_location_types.yaml new file mode 100644 index 0000000..00cfab8 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_location_types.yaml @@ -0,0 +1,15 @@ +table: + name: location_types + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id +array_relationships: + - name: locations + using: + foreign_key_constraint_on: + column: location_type_id + table: + name: locations + schema: public diff --git a/hasura/metadata/databases/default/tables/public_locations.yaml b/hasura/metadata/databases/default/tables/public_locations.yaml new file mode 100644 index 0000000..8adfa29 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_locations.yaml @@ -0,0 +1,45 @@ +table: + name: locations + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: location + using: + foreign_key_constraint_on: parent_id + - name: location_type + using: + foreign_key_constraint_on: location_type_id + - name: user + using: + foreign_key_constraint_on: manager_id +array_relationships: + - name: equipment + using: + foreign_key_constraint_on: + column: location_id + table: + name: equipment + schema: public + - name: issues + using: + foreign_key_constraint_on: + column: location_id + table: + name: issues + schema: public + - name: locations + using: + foreign_key_constraint_on: + column: parent_id + table: + name: locations + schema: public + - name: users + using: + foreign_key_constraint_on: + column: default_location_id + table: + name: users + schema: public diff --git a/hasura/metadata/databases/default/tables/public_maintenance_actions.yaml b/hasura/metadata/databases/default/tables/public_maintenance_actions.yaml new file mode 100644 index 0000000..7f75383 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_maintenance_actions.yaml @@ -0,0 +1,18 @@ +table: + name: maintenance_actions + schema: public +object_relationships: + - name: issue + using: + foreign_key_constraint_on: issue_id + - name: user + using: + foreign_key_constraint_on: technician_id +array_relationships: + - name: parts_useds + using: + foreign_key_constraint_on: + column: maintenance_action_id + table: + name: parts_used + schema: public diff --git a/hasura/metadata/databases/default/tables/public_notifications_log.yaml b/hasura/metadata/databases/default/tables/public_notifications_log.yaml new file mode 100644 index 0000000..be7c7b3 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_notifications_log.yaml @@ -0,0 +1,13 @@ +table: + name: notifications_log + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: issue + using: + foreign_key_constraint_on: issue_id + - name: user + using: + foreign_key_constraint_on: user_id diff --git a/hasura/metadata/databases/default/tables/public_parts_used.yaml b/hasura/metadata/databases/default/tables/public_parts_used.yaml new file mode 100644 index 0000000..619f775 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_parts_used.yaml @@ -0,0 +1,7 @@ +table: + name: parts_used + schema: public +object_relationships: + - name: maintenance_action + using: + foreign_key_constraint_on: maintenance_action_id diff --git a/hasura/metadata/databases/default/tables/public_preventive_schedules.yaml b/hasura/metadata/databases/default/tables/public_preventive_schedules.yaml new file mode 100644 index 0000000..9d27568 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_preventive_schedules.yaml @@ -0,0 +1,10 @@ +table: + name: preventive_schedules + schema: public +object_relationships: + - name: preventive_task + using: + foreign_key_constraint_on: task_id + - name: user + using: + foreign_key_constraint_on: completed_by diff --git a/hasura/metadata/databases/default/tables/public_preventive_tasks.yaml b/hasura/metadata/databases/default/tables/public_preventive_tasks.yaml new file mode 100644 index 0000000..c5388d5 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_preventive_tasks.yaml @@ -0,0 +1,18 @@ +table: + name: preventive_tasks + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: equipment + using: + foreign_key_constraint_on: equipment_id +array_relationships: + - name: preventive_schedules + using: + foreign_key_constraint_on: + column: task_id + table: + name: preventive_schedules + schema: public diff --git a/hasura/metadata/databases/default/tables/public_severity_levels.yaml b/hasura/metadata/databases/default/tables/public_severity_levels.yaml new file mode 100644 index 0000000..ed027a8 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_severity_levels.yaml @@ -0,0 +1,22 @@ +table: + name: severity_levels + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id +array_relationships: + - name: issue_categories + using: + foreign_key_constraint_on: + column: default_severity_id + table: + name: issue_categories + schema: public + - name: issues + using: + foreign_key_constraint_on: + column: severity_id + table: + name: issues + schema: public diff --git a/hasura/metadata/databases/default/tables/public_users.yaml b/hasura/metadata/databases/default/tables/public_users.yaml new file mode 100644 index 0000000..a1d2657 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_users.yaml @@ -0,0 +1,67 @@ +table: + name: users + schema: public +object_relationships: + - name: company + using: + foreign_key_constraint_on: company_id + - name: location + using: + foreign_key_constraint_on: default_location_id +array_relationships: + - name: issue_comments + using: + foreign_key_constraint_on: + column: author_id + table: + name: issue_comments + schema: public + - name: issue_photos + using: + foreign_key_constraint_on: + column: uploaded_by + table: + name: issue_photos + schema: public + - name: issues + using: + foreign_key_constraint_on: + column: assigned_to + table: + name: issues + schema: public + - name: issuesByReporterId + using: + foreign_key_constraint_on: + column: reporter_id + table: + name: issues + schema: public + - name: locations + using: + foreign_key_constraint_on: + column: manager_id + table: + name: locations + schema: public + - name: maintenance_actions + using: + foreign_key_constraint_on: + column: technician_id + table: + name: maintenance_actions + schema: public + - name: notifications_logs + using: + foreign_key_constraint_on: + column: user_id + table: + name: notifications_log + schema: public + - name: preventive_schedules + using: + foreign_key_constraint_on: + column: completed_by + table: + name: preventive_schedules + schema: public diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml new file mode 100644 index 0000000..15f9245 --- /dev/null +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -0,0 +1,19 @@ +- "!include public_companies.yaml" +- "!include public_equipment.yaml" +- "!include public_equipment_analytics.yaml" +- "!include public_equipment_categories.yaml" +- "!include public_equipment_photos.yaml" +- "!include public_industry_templates.yaml" +- "!include public_issue_categories.yaml" +- "!include public_issue_comments.yaml" +- "!include public_issue_photos.yaml" +- "!include public_issues.yaml" +- "!include public_location_types.yaml" +- "!include public_locations.yaml" +- "!include public_maintenance_actions.yaml" +- "!include public_notifications_log.yaml" +- "!include public_parts_used.yaml" +- "!include public_preventive_schedules.yaml" +- "!include public_preventive_tasks.yaml" +- "!include public_severity_levels.yaml" +- "!include public_users.yaml" diff --git a/hasura/metadata/graphql_schema_introspection.yaml b/hasura/metadata/graphql_schema_introspection.yaml new file mode 100644 index 0000000..61a4dca --- /dev/null +++ b/hasura/metadata/graphql_schema_introspection.yaml @@ -0,0 +1 @@ +disabled_for_roles: [] diff --git a/hasura/metadata/inherited_roles.yaml b/hasura/metadata/inherited_roles.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/inherited_roles.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/metrics_config.yaml b/hasura/metadata/metrics_config.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/metrics_config.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/network.yaml b/hasura/metadata/network.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/network.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/opentelemetry.yaml b/hasura/metadata/opentelemetry.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/hasura/metadata/opentelemetry.yaml @@ -0,0 +1 @@ +{} diff --git a/hasura/metadata/query_collections.yaml b/hasura/metadata/query_collections.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/query_collections.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/remote_schemas.yaml b/hasura/metadata/remote_schemas.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/remote_schemas.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/rest_endpoints.yaml b/hasura/metadata/rest_endpoints.yaml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/hasura/metadata/rest_endpoints.yaml @@ -0,0 +1 @@ +[] diff --git a/hasura/metadata/version.yaml b/hasura/metadata/version.yaml new file mode 100644 index 0000000..0a70aff --- /dev/null +++ b/hasura/metadata/version.yaml @@ -0,0 +1 @@ +version: 3 diff --git a/hasura/migrations/001_companies.down.sql b/hasura/migrations/001_companies.down.sql new file mode 100644 index 0000000..eda7a66 --- /dev/null +++ b/hasura/migrations/001_companies.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS companies; diff --git a/hasura/migrations/001_companies.up.sql b/hasura/migrations/001_companies.up.sql new file mode 100644 index 0000000..67f8dd0 --- /dev/null +++ b/hasura/migrations/001_companies.up.sql @@ -0,0 +1,14 @@ +-- 001_companies.up.sql + +CREATE TABLE companies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, -- URL-safe, immutable after creation + industry TEXT NOT NULL, -- restaurant | hotel | apartment | office | hospital | custom + plan TEXT NOT NULL DEFAULT 'trial', -- trial | starter | pro | enterprise + timezone TEXT NOT NULL DEFAULT 'UTC', -- fallback; locations override per-site + branding JSONB NOT NULL DEFAULT '{}', -- { primaryColor, logoUrl, emailFrom } + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_companies_slug ON companies (slug); diff --git a/hasura/migrations/002_location_types.down.sql b/hasura/migrations/002_location_types.down.sql new file mode 100644 index 0000000..edf57cd --- /dev/null +++ b/hasura/migrations/002_location_types.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS location_types; diff --git a/hasura/migrations/002_location_types.up.sql b/hasura/migrations/002_location_types.up.sql new file mode 100644 index 0000000..6934de8 --- /dev/null +++ b/hasura/migrations/002_location_types.up.sql @@ -0,0 +1,13 @@ +-- 002_location_types.up.sql +-- Defines the labels for each depth level in a company's location hierarchy. +-- Seeded from industry_templates on company signup; companies customise from there. + +CREATE TABLE location_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + name TEXT NOT NULL, -- e.g. "Restaurant", "Floor", "Wing" + icon TEXT, -- icon key for the frontend + expected_depth INT NOT NULL -- 0 = top-level, 1 = child, 2 = grandchild +); + +CREATE INDEX idx_location_types_company ON location_types (company_id); diff --git a/hasura/migrations/003_locations.down.sql b/hasura/migrations/003_locations.down.sql new file mode 100644 index 0000000..0f9a7f9 --- /dev/null +++ b/hasura/migrations/003_locations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS locations; diff --git a/hasura/migrations/003_locations.up.sql b/hasura/migrations/003_locations.up.sql new file mode 100644 index 0000000..7f600a0 --- /dev/null +++ b/hasura/migrations/003_locations.up.sql @@ -0,0 +1,30 @@ +-- 003_locations.up.sql +-- Self-referencing table supports arbitrary hierarchy depth per company. +-- The `path` column is materialised (slug/child/grandchild) for fast subtree queries. + +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + parent_id UUID REFERENCES locations (id) ON DELETE RESTRICT, -- null = root node + location_type_id UUID NOT NULL REFERENCES location_types (id), + -- manager_id added in 007 after users table exists (FK forward reference avoided) + name TEXT NOT NULL, + -- Materialised path: "tasca-do-porto/kitchen" + -- Built and maintained by the Go service on insert/rename. + path TEXT NOT NULL, + depth INT NOT NULL DEFAULT 0, + timezone TEXT, -- overrides companies.timezone when set + address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_location_path_per_company UNIQUE (company_id, path) +); + +-- Multi-tenant isolation — every locations query starts here +CREATE INDEX idx_locations_company ON locations (company_id); + +-- Hierarchy traversal +CREATE INDEX idx_locations_parent ON locations (parent_id); + +-- Fast subtree queries: WHERE path LIKE 'root/child%' +CREATE INDEX idx_locations_path ON locations (company_id, path text_pattern_ops); diff --git a/hasura/migrations/004_severity_levels.down.sql b/hasura/migrations/004_severity_levels.down.sql new file mode 100644 index 0000000..a456b94 --- /dev/null +++ b/hasura/migrations/004_severity_levels.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS severity_levels; diff --git a/hasura/migrations/004_severity_levels.up.sql b/hasura/migrations/004_severity_levels.up.sql new file mode 100644 index 0000000..a0191f3 --- /dev/null +++ b/hasura/migrations/004_severity_levels.up.sql @@ -0,0 +1,16 @@ +-- 004_severity_levels.up.sql +-- Per-company configurable severity levels. +-- Replaces hardcoded enums — a hospital has 30-min Critical; a restaurant has 4h. + +CREATE TABLE severity_levels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + name TEXT NOT NULL, -- "Critical", "High", "Medium", "Low" + color TEXT NOT NULL, -- hex color for the frontend badge + sla_hours INT NOT NULL, -- target resolution time in hours + sms_alert BOOLEAN NOT NULL DEFAULT FALSE, -- triggers Twilio on issue creation + bypass_quiet_hours BOOLEAN NOT NULL DEFAULT FALSE, -- ignores user quiet-hour prefs + sort_order INT NOT NULL DEFAULT 0 -- display order (0 = highest) +); + +CREATE INDEX idx_severity_levels_company ON severity_levels (company_id, sort_order); diff --git a/hasura/migrations/005_issue_categories.down.sql b/hasura/migrations/005_issue_categories.down.sql new file mode 100644 index 0000000..c95c5e7 --- /dev/null +++ b/hasura/migrations/005_issue_categories.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS issue_categories; diff --git a/hasura/migrations/005_issue_categories.up.sql b/hasura/migrations/005_issue_categories.up.sql new file mode 100644 index 0000000..8ad18ea --- /dev/null +++ b/hasura/migrations/005_issue_categories.up.sql @@ -0,0 +1,13 @@ +-- 005_issue_categories.up.sql +-- Per-company issue categories. default_severity_id pre-fills severity on the report form. +-- e.g. "Safety Hazard" → default = Critical. + +CREATE TABLE issue_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + name TEXT NOT NULL, -- "Broken", "Safety Hazard", "Maintenance Due" + icon TEXT, -- icon key + default_severity_id UUID REFERENCES severity_levels (id) ON DELETE SET NULL +); + +CREATE INDEX idx_issue_categories_company ON issue_categories (company_id); diff --git a/hasura/migrations/006_equipment_categories.down.sql b/hasura/migrations/006_equipment_categories.down.sql new file mode 100644 index 0000000..e563787 --- /dev/null +++ b/hasura/migrations/006_equipment_categories.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS equipment_categories; diff --git a/hasura/migrations/006_equipment_categories.up.sql b/hasura/migrations/006_equipment_categories.up.sql new file mode 100644 index 0000000..811d6df --- /dev/null +++ b/hasura/migrations/006_equipment_categories.up.sql @@ -0,0 +1,11 @@ +-- 006_equipment_categories.up.sql + +CREATE TABLE equipment_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + name TEXT NOT NULL, -- "Refrigeration", "Cooking", "HVAC" + icon TEXT, + default_pm_interval_days INT -- pre-fills PM task frequency on creation +); + +CREATE INDEX idx_equipment_categories_company ON equipment_categories (company_id); diff --git a/hasura/migrations/007_users.down.sql b/hasura/migrations/007_users.down.sql new file mode 100644 index 0000000..401feef --- /dev/null +++ b/hasura/migrations/007_users.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE locations DROP COLUMN IF EXISTS manager_id; +DROP TABLE IF EXISTS users; diff --git a/hasura/migrations/007_users.up.sql b/hasura/migrations/007_users.up.sql new file mode 100644 index 0000000..73a53bc --- /dev/null +++ b/hasura/migrations/007_users.up.sql @@ -0,0 +1,28 @@ +-- 007_users.up.sql +-- Users are created after config tables so role FKs resolve cleanly. +-- notification_prefs JSONB: { push: bool, email: bool, sms: bool, quiet_start: "23:00", quiet_end: "07:00" } + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + -- role is denormalised as text; Hasura permission rules read x-hasura-default-role from JWT + role TEXT NOT NULL + CHECK (role IN ('employee','technician','location_manager','ops_manager','admin','super_admin')), + default_location_id UUID REFERENCES locations (id) ON DELETE SET NULL, + notification_prefs JSONB NOT NULL DEFAULT '{"push":true,"email":true,"sms":false}', + password_hash TEXT, -- null when using magic-link only + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_users_email_per_company UNIQUE (company_id, email) +); + +CREATE INDEX idx_users_company ON users (company_id); +CREATE INDEX idx_users_email ON users (email); + +-- ── Now that users exists, add the manager_id FK to locations ── +ALTER TABLE locations + ADD COLUMN manager_id UUID REFERENCES users (id) ON DELETE SET NULL; diff --git a/hasura/migrations/008_equipment.down.sql b/hasura/migrations/008_equipment.down.sql new file mode 100644 index 0000000..e8d492b --- /dev/null +++ b/hasura/migrations/008_equipment.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS equipment_photos; +DROP TABLE IF EXISTS equipment; diff --git a/hasura/migrations/008_equipment.up.sql b/hasura/migrations/008_equipment.up.sql new file mode 100644 index 0000000..d4fbfdb --- /dev/null +++ b/hasura/migrations/008_equipment.up.sql @@ -0,0 +1,64 @@ +-- 008_equipment.up.sql + +CREATE TABLE equipment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + location_id UUID NOT NULL REFERENCES locations (id) ON DELETE RESTRICT, + category_id UUID REFERENCES equipment_categories (id) ON DELETE SET NULL, + + -- Phase 1: always NULL. Phase 2: set on high-value components. + -- Signal to promote: when you're filtering maintenance_actions.component_name across locations. + parent_equipment_id UUID REFERENCES equipment (id) ON DELETE SET NULL, + is_component BOOLEAN NOT NULL DEFAULT FALSE, + + -- Identity + name TEXT NOT NULL, + serial_number TEXT, + manufacturer TEXT, + model TEXT, + + -- Lifecycle + install_date DATE, + warranty_expiry DATE, + purchase_cost_cents INT, -- stored in minor currency units (cents) + expected_lifespan_years INT, + + -- State + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','under_repair','decommissioned')), + + -- QR system: format EQ-00142, unique, never reused after decommission + qr_code_id TEXT UNIQUE, + + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Multi-tenant isolation +CREATE INDEX idx_equipment_company ON equipment (company_id, location_id, status); + +-- Manager dashboard: all equipment for a location +CREATE INDEX idx_equipment_location ON equipment (location_id, status); + +-- QR scan-to-report (hot path — every employee scan hits this) +CREATE INDEX idx_equipment_qr ON equipment (qr_code_id); + +-- Component queries (sparse — only populated Phase 2+) +CREATE INDEX idx_equipment_parent ON equipment (parent_equipment_id) + WHERE parent_equipment_id IS NOT NULL; + +-- Warranty expiry alerts (Go cron scans 30 days ahead) +CREATE INDEX idx_equipment_warranty ON equipment (warranty_expiry) + WHERE status = 'active' AND warranty_expiry IS NOT NULL; + +-- ──────────────────────────────────────────── +CREATE TABLE equipment_photos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + equipment_id UUID NOT NULL REFERENCES equipment (id) ON DELETE CASCADE, + storage_url TEXT NOT NULL, + caption TEXT, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_equipment_photos_equipment ON equipment_photos (equipment_id); diff --git a/hasura/migrations/009_issues.down.sql b/hasura/migrations/009_issues.down.sql new file mode 100644 index 0000000..8856a22 --- /dev/null +++ b/hasura/migrations/009_issues.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS issue_comments; +DROP TABLE IF EXISTS issue_photos; +DROP TABLE IF EXISTS issues; diff --git a/hasura/migrations/009_issues.up.sql b/hasura/migrations/009_issues.up.sql new file mode 100644 index 0000000..21805fa --- /dev/null +++ b/hasura/migrations/009_issues.up.sql @@ -0,0 +1,84 @@ +-- 009_issues.up.sql +-- The most frequently read and written table in the system. +-- location_id and company_id are denormalised to avoid joins on hot query paths. + +CREATE TABLE issues ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Denormalised for query performance — both reachable via equipment FK but expensive to join + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + location_id UUID NOT NULL REFERENCES locations (id) ON DELETE RESTRICT, + + equipment_id UUID NOT NULL REFERENCES equipment (id) ON DELETE RESTRICT, + category_id UUID REFERENCES issue_categories (id) ON DELETE SET NULL, + severity_id UUID NOT NULL REFERENCES severity_levels (id) ON DELETE RESTRICT, + + reporter_id UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + assigned_to UUID REFERENCES users (id) ON DELETE SET NULL, + + -- 7-state lifecycle + status TEXT NOT NULL DEFAULT 'reported' + CHECK (status IN ('reported','reviewed','assigned','in_progress','awaiting_parts','resolved','closed')), + + title TEXT NOT NULL, + description TEXT, + + -- Full timestamp chain — enables MTTR, time-to-assign, and SLA metrics + reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sla_deadline TIMESTAMPTZ NOT NULL, -- computed at insert: reported_at + severity.sla_hours + assigned_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + resolution_notes TEXT +); + +-- ── Indexes — ordered by query frequency ────────────────── + +-- Multi-tenant isolation (outermost filter on every query) +CREATE INDEX idx_issues_company ON issues (company_id, status, reported_at DESC); + +-- Manager dashboard: all open issues for a location +CREATE INDEX idx_issues_location ON issues (location_id, status, severity_id); + +-- Equipment history page +CREATE INDEX idx_issues_equipment ON issues (equipment_id, status, reported_at DESC); + +-- Technician's job list +CREATE INDEX idx_issues_assigned ON issues (assigned_to, status) + WHERE assigned_to IS NOT NULL; + +-- SLA monitor cron (runs every 15 min — must be fast) +CREATE INDEX idx_issues_sla ON issues (sla_deadline) + WHERE status NOT IN ('resolved','closed'); + +-- Duplicate detection on QR scan: same equipment + recent open issue +CREATE INDEX idx_issues_dup_check ON issues (equipment_id, reported_at DESC) + WHERE status NOT IN ('resolved','closed'); + + +-- ── issue_photos ────────────────────────────────────────── + +CREATE TABLE issue_photos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issues (id) ON DELETE CASCADE, + storage_url TEXT NOT NULL, + -- stage tracks when in the lifecycle the photo was taken + stage TEXT NOT NULL CHECK (stage IN ('reported','in_progress','resolved')), + uploaded_by UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_issue_photos_issue ON issue_photos (issue_id); + + +-- ── issue_comments ──────────────────────────────────────── + +CREATE TABLE issue_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issues (id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_issue_comments_issue ON issue_comments (issue_id, created_at); diff --git a/hasura/migrations/010_maintenance_actions.down.sql b/hasura/migrations/010_maintenance_actions.down.sql new file mode 100644 index 0000000..95df892 --- /dev/null +++ b/hasura/migrations/010_maintenance_actions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS maintenance_actions; diff --git a/hasura/migrations/010_maintenance_actions.up.sql b/hasura/migrations/010_maintenance_actions.up.sql new file mode 100644 index 0000000..b94ae80 --- /dev/null +++ b/hasura/migrations/010_maintenance_actions.up.sql @@ -0,0 +1,42 @@ +-- 010_maintenance_actions.up.sql +-- One issue can have multiple actions (technician starts, orders parts, returns to finish). +-- component_type + component_name is the Phase 1 sub-equipment tracking strategy — +-- structured text on the action rather than a full component entity hierarchy. + +CREATE TABLE maintenance_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issues (id) ON DELETE CASCADE, + technician_id UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + + action_description TEXT NOT NULL, + + -- root_cause is a constrained enum for analytics grouping + root_cause TEXT CHECK (root_cause IN ( + 'wear_and_tear', + 'user_error', + 'manufacturing_defect', + 'lack_of_maintenance', + 'unknown', + 'other' + )), + + -- Phase 1 component tracking: structured text, not a FK to a component table. + -- Enables "how many compressors replaced this year" via simple GROUP BY. + -- Phase 2: promote to parent_equipment_id on equipment table when query pain warrants it. + component_type TEXT, -- e.g. "refrigeration", "cooking" + component_name TEXT, -- e.g. "compressor", "heating_element" + + -- labor_minutes stored explicitly — technicians sometimes correct clock-out time manually. + -- Derived from end_time - start_time as default, but can diverge. + labor_minutes INT, + start_time TIMESTAMPTZ, + end_time TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_maintenance_actions_issue ON maintenance_actions (issue_id); +CREATE INDEX idx_maintenance_actions_technician ON maintenance_actions (technician_id); + +-- Analytics: filter by component across all actions (Phase 1 query pattern) +CREATE INDEX idx_maintenance_actions_component ON maintenance_actions (component_name) + WHERE component_name IS NOT NULL; diff --git a/hasura/migrations/011_parts_used.down.sql b/hasura/migrations/011_parts_used.down.sql new file mode 100644 index 0000000..54a5c92 --- /dev/null +++ b/hasura/migrations/011_parts_used.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS parts_used; diff --git a/hasura/migrations/011_parts_used.up.sql b/hasura/migrations/011_parts_used.up.sql new file mode 100644 index 0000000..b9c88cd --- /dev/null +++ b/hasura/migrations/011_parts_used.up.sql @@ -0,0 +1,20 @@ +-- 011_parts_used.up.sql +-- Financial goldmine: every part logged here enables: +-- total cost per repair, lifetime cost per equipment, +-- most-replaced parts, supplier price comparison. + +CREATE TABLE parts_used ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + maintenance_action_id UUID NOT NULL REFERENCES maintenance_actions (id) ON DELETE CASCADE, + part_name TEXT NOT NULL, + part_number TEXT, + quantity INT NOT NULL DEFAULT 1, + unit_cost_cents INT NOT NULL DEFAULT 0, -- minor currency units, never FLOAT + supplier TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_parts_used_action ON parts_used (maintenance_action_id); + +-- Cost aggregation by part name across all repairs +CREATE INDEX idx_parts_used_name ON parts_used (part_name); diff --git a/hasura/migrations/012_preventive_tasks.down.sql b/hasura/migrations/012_preventive_tasks.down.sql new file mode 100644 index 0000000..5111888 --- /dev/null +++ b/hasura/migrations/012_preventive_tasks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS preventive_tasks; diff --git a/hasura/migrations/012_preventive_tasks.up.sql b/hasura/migrations/012_preventive_tasks.up.sql new file mode 100644 index 0000000..092d11e --- /dev/null +++ b/hasura/migrations/012_preventive_tasks.up.sql @@ -0,0 +1,24 @@ +-- 012_preventive_tasks.up.sql +-- Template table: defines WHAT to do and HOW OFTEN. +-- assigned_role is a string (not a FK to users) — the task targets a role, +-- not a specific person. The person is determined at completion time. + +CREATE TABLE preventive_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + equipment_id UUID NOT NULL REFERENCES equipment (id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + frequency_days INT NOT NULL, -- e.g. 30 = monthly, 90 = quarterly + assigned_role TEXT NOT NULL DEFAULT 'technician' + CHECK (assigned_role IN ('employee','technician','location_manager')), + estimated_minutes INT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_preventive_tasks_equipment ON preventive_tasks (equipment_id) + WHERE is_active = TRUE; + +CREATE INDEX idx_preventive_tasks_company ON preventive_tasks (company_id) + WHERE is_active = TRUE; diff --git a/hasura/migrations/013_preventive_schedules.down.sql b/hasura/migrations/013_preventive_schedules.down.sql new file mode 100644 index 0000000..933dbc6 --- /dev/null +++ b/hasura/migrations/013_preventive_schedules.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS preventive_schedules; diff --git a/hasura/migrations/013_preventive_schedules.up.sql b/hasura/migrations/013_preventive_schedules.up.sql new file mode 100644 index 0000000..00139ea --- /dev/null +++ b/hasura/migrations/013_preventive_schedules.up.sql @@ -0,0 +1,30 @@ +-- 013_preventive_schedules.up.sql +-- Generated instances from preventive_tasks templates. +-- The Go daily cron creates schedule records 30 days ahead. +-- Cron logic: for every active task, if no pending schedule exists +-- within the next 30 days → INSERT with due_date = last_completion + frequency_days. +-- This operation is idempotent — running it twice does not double-create records. + +CREATE TABLE preventive_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES preventive_tasks (id) ON DELETE CASCADE, + due_date DATE NOT NULL, + completed_at TIMESTAMPTZ, + completed_by UUID REFERENCES users (id) ON DELETE SET NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','completed','overdue')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- SLA monitor + overdue escalation cron (runs daily) +-- Partial index: only pending/overdue rows are ever scanned by the cron +CREATE INDEX idx_pm_schedules_due ON preventive_schedules (due_date, status) + WHERE status IN ('pending','overdue'); + +-- Task history: all schedules for a given task +CREATE INDEX idx_pm_schedules_task ON preventive_schedules (task_id, due_date DESC); + +-- Dashboard: completed schedules by user +CREATE INDEX idx_pm_schedules_completer ON preventive_schedules (completed_by) + WHERE completed_by IS NOT NULL; diff --git a/hasura/migrations/014_equipment_analytics.down.sql b/hasura/migrations/014_equipment_analytics.down.sql new file mode 100644 index 0000000..4f16b29 --- /dev/null +++ b/hasura/migrations/014_equipment_analytics.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS equipment_analytics; diff --git a/hasura/migrations/014_equipment_analytics.up.sql b/hasura/migrations/014_equipment_analytics.up.sql new file mode 100644 index 0000000..093eed9 --- /dev/null +++ b/hasura/migrations/014_equipment_analytics.up.sql @@ -0,0 +1,47 @@ +-- 014_equipment_analytics.up.sql +-- Pre-aggregated nightly by the Go cron job. +-- Dashboards READ ONLY from this table — never live-aggregate across +-- millions of issue/maintenance_action rows at query time. +-- +-- One row per (equipment_id, period_date) — period_date is the first day +-- of the month being summarised. The cron overwrites the current month's +-- row on each nightly run and inserts a final record on month close. + +CREATE TABLE equipment_analytics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + equipment_id UUID NOT NULL REFERENCES equipment (id) ON DELETE CASCADE, + + -- period_date = first day of the month (2024-11-01, 2024-12-01, ...) + period_date DATE NOT NULL, + + -- Counts + issue_count INT NOT NULL DEFAULT 0, + + -- MTBF: average days between issue reports in this period. + -- NULL when fewer than 2 issues exist (can't compute interval). + mtbf_days INT, + + -- MTTR: average hours from reported_at to resolved_at in this period. + -- NULL when no issues were resolved. + mttr_hours INT, + + -- Financials (cents, same convention as all other monetary columns) + total_cost_cents INT NOT NULL DEFAULT 0, + + -- Downtime: sum of (resolved_at - reported_at) for all issues in period, in minutes. + downtime_minutes INT NOT NULL DEFAULT 0, + + -- Composite 0–100 score: normalised MTBF trend + cost ratio + age ratio. + -- Formula weights are stored in companies.branding JSONB (Phase 2). + health_score NUMERIC(5,2), -- 0.00–100.00; NUMERIC not FLOAT (deterministic rounding) + + calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_analytics_equipment_period UNIQUE (equipment_id, period_date) +); + +-- Dashboard reads: latest period for a given equipment item +CREATE INDEX idx_analytics_equipment ON equipment_analytics (equipment_id, period_date DESC); + +-- Cross-location analytics: all equipment in a period +CREATE INDEX idx_analytics_period ON equipment_analytics (period_date); diff --git a/hasura/migrations/015_notifications_log.down.sql b/hasura/migrations/015_notifications_log.down.sql new file mode 100644 index 0000000..0fa4bd8 --- /dev/null +++ b/hasura/migrations/015_notifications_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS notifications_log; diff --git a/hasura/migrations/015_notifications_log.up.sql b/hasura/migrations/015_notifications_log.up.sql new file mode 100644 index 0000000..f6604a8 --- /dev/null +++ b/hasura/migrations/015_notifications_log.up.sql @@ -0,0 +1,39 @@ +-- 015_notifications_log.up.sql +-- Immutable audit trail for every notification attempt. +-- Two purposes: +-- 1. Debugging: "why didn't the manager get the critical SMS?" +-- 2. Compliance: GDPR audit trail of what was communicated to whom and when. +-- +-- delivered_at is populated by Postmark / Twilio delivery webhooks. +-- If sent_at is set but delivered_at is NULL after 30 min → Go service alerts on failure. + +CREATE TABLE notifications_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES companies (id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + + -- issue_id is nullable — some notifications are not issue-related + -- (e.g. warranty expiry, monthly report ready) + issue_id UUID REFERENCES issues (id) ON DELETE SET NULL, + + event_type TEXT NOT NULL, -- issue_created | issue_assigned | sla_breached | pm_due | ... + channel TEXT NOT NULL CHECK (channel IN ('email','sms','push')), + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delivered_at TIMESTAMPTZ, -- populated by webhook callback + status TEXT NOT NULL DEFAULT 'sent' + CHECK (status IN ('sent','delivered','failed','bounced')) +); + +-- Delivery audit: find all notifications for a user +CREATE INDEX idx_notifications_user ON notifications_log (user_id, sent_at DESC); + +-- Delivery audit: all notifications for an issue +CREATE INDEX idx_notifications_issue ON notifications_log (issue_id) + WHERE issue_id IS NOT NULL; + +-- Failure detection cron: undelivered notifications older than 30 min +CREATE INDEX idx_notifications_undelivered ON notifications_log (sent_at) + WHERE delivered_at IS NULL AND status = 'sent'; + +-- Multi-tenant isolation +CREATE INDEX idx_notifications_company ON notifications_log (company_id, sent_at DESC); diff --git a/hasura/migrations/016_industry_templates.down.sql b/hasura/migrations/016_industry_templates.down.sql new file mode 100644 index 0000000..2a638e4 --- /dev/null +++ b/hasura/migrations/016_industry_templates.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS industry_templates; diff --git a/hasura/migrations/016_industry_templates.up.sql b/hasura/migrations/016_industry_templates.up.sql new file mode 100644 index 0000000..89874fb --- /dev/null +++ b/hasura/migrations/016_industry_templates.up.sql @@ -0,0 +1,244 @@ +-- 016_industry_templates.up.sql +-- Static reference data — NOT per-company. One row per supported industry vertical. +-- Read once by the Go onboarding service at company signup to seed the +-- per-company config tables (location_types, severity_levels, issue_categories, +-- equipment_categories, preventive_tasks). +-- Companies customise from there; nothing in this table is ever mutated post-seed. + +CREATE TABLE industry_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + industry TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + + -- JSONB arrays — each element maps 1:1 to a row in the target config table. + -- The Go onboarding service iterates these and INSERTs with the new company_id. + default_location_types JSONB NOT NULL DEFAULT '[]', + default_equipment_categories JSONB NOT NULL DEFAULT '[]', + default_issue_categories JSONB NOT NULL DEFAULT '[]', + default_severity_levels JSONB NOT NULL DEFAULT '[]', + default_pm_tasks JSONB NOT NULL DEFAULT '[]' +); + +-- ── Seed data ───────────────────────────────────────────────────────────────── + +INSERT INTO industry_templates (industry, name, default_location_types, default_equipment_categories, default_issue_categories, default_severity_levels, default_pm_tasks) VALUES + +-- ── Restaurant / Food Service ────────────────────────────────────────────── +('restaurant', 'Restaurant / Food Service', + +'[ + {"name":"Restaurant","icon":"store","expected_depth":0}, + {"name":"Area","icon":"layout","expected_depth":1} +]', + +'[ + {"name":"Refrigeration","icon":"thermometer","default_pm_interval_days":30}, + {"name":"Cooking","icon":"flame","default_pm_interval_days":7}, + {"name":"Dishwashing","icon":"droplets","default_pm_interval_days":7}, + {"name":"HVAC","icon":"wind","default_pm_interval_days":90}, + {"name":"Ventilation","icon":"airplay","default_pm_interval_days":30}, + {"name":"Electrical","icon":"zap","default_pm_interval_days":365}, + {"name":"Plumbing","icon":"pipe","default_pm_interval_days":180}, + {"name":"POS / Tech","icon":"monitor","default_pm_interval_days":365}, + {"name":"Safety","icon":"shield","default_pm_interval_days":365} +]', + +'[ + {"name":"Broken / Not Working","icon":"x-circle","default_severity":"high"}, + {"name":"Degraded Performance","icon":"trending-down","default_severity":"medium"}, + {"name":"Safety Hazard","icon":"alert-triangle","default_severity":"critical"}, + {"name":"Maintenance Due","icon":"clock","default_severity":"low"}, + {"name":"Cosmetic / Minor","icon":"eye","default_severity":"low"}, + {"name":"Other","icon":"more-horizontal","default_severity":"medium"} +]', + +'[ + {"name":"Critical","color":"#E24B4A","sla_hours":4, "sms_alert":true, "bypass_quiet_hours":true, "sort_order":0}, + {"name":"High", "color":"#EF9F27","sla_hours":24, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":1}, + {"name":"Medium", "color":"#378ADD","sla_hours":72, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":2}, + {"name":"Low", "color":"#888780","sla_hours":168,"sms_alert":false,"bypass_quiet_hours":false,"sort_order":3} +]', + +'[ + {"title":"Clean condenser coils", "equipment_category":"Refrigeration","frequency_days":30, "assigned_role":"technician","estimated_minutes":30}, + {"title":"Check door seals", "equipment_category":"Refrigeration","frequency_days":30, "assigned_role":"employee", "estimated_minutes":10}, + {"title":"Full descale and sanitize","equipment_category":"Dishwashing", "frequency_days":30, "assigned_role":"employee", "estimated_minutes":45}, + {"title":"Clean wash arms", "equipment_category":"Dishwashing", "frequency_days":7, "assigned_role":"employee", "estimated_minutes":20}, + {"title":"Degrease filters", "equipment_category":"Ventilation", "frequency_days":30, "assigned_role":"employee", "estimated_minutes":30}, + {"title":"Replace HVAC filter", "equipment_category":"HVAC", "frequency_days":90, "assigned_role":"technician","estimated_minutes":20}, + {"title":"Fryer full clean", "equipment_category":"Cooking", "frequency_days":7, "assigned_role":"employee", "estimated_minutes":60}, + {"title":"Coffee machine descale", "equipment_category":"Cooking", "frequency_days":30, "assigned_role":"employee", "estimated_minutes":30}, + {"title":"Ice machine descale", "equipment_category":"Refrigeration","frequency_days":90, "assigned_role":"technician","estimated_minutes":60} +]'), + +-- ── Hotel / Hospitality ──────────────────────────────────────────────────── +('hotel', 'Hotel / Hospitality', + +'[ + {"name":"Property","icon":"building","expected_depth":0}, + {"name":"Floor", "icon":"layers", "expected_depth":1}, + {"name":"Room", "icon":"door-open","expected_depth":2} +]', + +'[ + {"name":"HVAC", "icon":"wind", "default_pm_interval_days":90}, + {"name":"Plumbing", "icon":"pipe", "default_pm_interval_days":180}, + {"name":"Electrical", "icon":"zap", "default_pm_interval_days":365}, + {"name":"Lifts / Elevators", "icon":"arrow-up-down","default_pm_interval_days":90}, + {"name":"Pool & Spa", "icon":"waves", "default_pm_interval_days":1}, + {"name":"AV Systems", "icon":"tv", "default_pm_interval_days":180}, + {"name":"Access Control", "icon":"lock", "default_pm_interval_days":180}, + {"name":"Room Furniture & Fixtures","icon":"sofa", "default_pm_interval_days":365} +]', + +'[ + {"name":"Room Defect", "icon":"home", "default_severity":"medium"}, + {"name":"Safety", "icon":"alert-triangle", "default_severity":"critical"}, + {"name":"Compliance", "icon":"file-check", "default_severity":"high"}, + {"name":"Guest-Reported", "icon":"user", "default_severity":"high"}, + {"name":"Preventive", "icon":"clock", "default_severity":"low"}, + {"name":"Cosmetic", "icon":"eye", "default_severity":"low"} +]', + +'[ + {"name":"Critical","color":"#E24B4A","sla_hours":2, "sms_alert":true, "bypass_quiet_hours":true, "sort_order":0}, + {"name":"High", "color":"#EF9F27","sla_hours":8, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":1}, + {"name":"Medium", "color":"#378ADD","sla_hours":48, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":2}, + {"name":"Low", "color":"#888780","sla_hours":168,"sms_alert":false,"bypass_quiet_hours":false,"sort_order":3} +]', + +'[ + {"title":"Pool chemistry check", "equipment_category":"Pool & Spa", "frequency_days":1, "assigned_role":"technician","estimated_minutes":20}, + {"title":"Lift annual service", "equipment_category":"Lifts / Elevators", "frequency_days":365,"assigned_role":"technician","estimated_minutes":240}, + {"title":"Room HVAC filter", "equipment_category":"HVAC", "frequency_days":90, "assigned_role":"technician","estimated_minutes":15}, + {"title":"Fire system inspection", "equipment_category":"Safety", "frequency_days":365,"assigned_role":"technician","estimated_minutes":120} +]'), + +-- ── Residential / Apartments ─────────────────────────────────────────────── +('apartment', 'Residential / Apartments', + +'[ + {"name":"Building","icon":"building-2","expected_depth":0}, + {"name":"Floor", "icon":"layers", "expected_depth":1}, + {"name":"Unit", "icon":"home", "expected_depth":2} +]', + +'[ + {"name":"Boilers & Heating","icon":"flame", "default_pm_interval_days":365}, + {"name":"Plumbing", "icon":"pipe", "default_pm_interval_days":180}, + {"name":"Electrical", "icon":"zap", "default_pm_interval_days":365}, + {"name":"Lifts / Elevators","icon":"arrow-up-down","default_pm_interval_days":90}, + {"name":"Common Areas", "icon":"layout", "default_pm_interval_days":30}, + {"name":"Safety Systems", "icon":"shield", "default_pm_interval_days":365}, + {"name":"Intercom / Access","icon":"lock", "default_pm_interval_days":180} +]', + +'[ + {"name":"Broken / Not Working","icon":"x-circle", "default_severity":"high"}, + {"name":"Leak / Water Damage", "icon":"droplets", "default_severity":"critical"}, + {"name":"Safety Hazard", "icon":"alert-triangle", "default_severity":"critical"}, + {"name":"Noise / Disturbance", "icon":"volume-2", "default_severity":"medium"}, + {"name":"Maintenance Due", "icon":"clock", "default_severity":"low"}, + {"name":"Cosmetic", "icon":"eye", "default_severity":"low"} +]', + +'[ + {"name":"Critical","color":"#E24B4A","sla_hours":4, "sms_alert":true, "bypass_quiet_hours":true, "sort_order":0}, + {"name":"High", "color":"#EF9F27","sla_hours":24, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":1}, + {"name":"Medium", "color":"#378ADD","sla_hours":72, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":2}, + {"name":"Low", "color":"#888780","sla_hours":168,"sms_alert":false,"bypass_quiet_hours":false,"sort_order":3} +]', + +'[ + {"title":"Boiler annual service", "equipment_category":"Boilers & Heating","frequency_days":365,"assigned_role":"technician","estimated_minutes":180}, + {"title":"Lift quarterly check", "equipment_category":"Lifts / Elevators","frequency_days":90, "assigned_role":"technician","estimated_minutes":60}, + {"title":"Fire alarm test", "equipment_category":"Safety Systems", "frequency_days":30, "assigned_role":"technician","estimated_minutes":30}, + {"title":"Common area inspection", "equipment_category":"Common Areas", "frequency_days":7, "assigned_role":"employee", "estimated_minutes":20} +]'), + +-- ── Office / Commercial ──────────────────────────────────────────────────── +('office', 'Office / Commercial', + +'[ + {"name":"Campus", "icon":"map", "expected_depth":0}, + {"name":"Building","icon":"building", "expected_depth":1}, + {"name":"Floor", "icon":"layers", "expected_depth":2}, + {"name":"Zone", "icon":"grid", "expected_depth":3} +]', + +'[ + {"name":"HVAC", "icon":"wind", "default_pm_interval_days":90}, + {"name":"Electrical", "icon":"zap", "default_pm_interval_days":365}, + {"name":"Plumbing", "icon":"pipe", "default_pm_interval_days":180}, + {"name":"AV Systems", "icon":"tv", "default_pm_interval_days":180}, + {"name":"Access Control", "icon":"lock", "default_pm_interval_days":180}, + {"name":"IT Infrastructure","icon":"server","default_pm_interval_days":90}, + {"name":"Safety", "icon":"shield", "default_pm_interval_days":365} +]', + +'[ + {"name":"Broken / Not Working","icon":"x-circle", "default_severity":"high"}, + {"name":"Safety Hazard", "icon":"alert-triangle", "default_severity":"critical"}, + {"name":"IT / Network", "icon":"wifi", "default_severity":"high"}, + {"name":"Maintenance Due", "icon":"clock", "default_severity":"low"}, + {"name":"Compliance", "icon":"file-check", "default_severity":"high"}, + {"name":"Cosmetic", "icon":"eye", "default_severity":"low"} +]', + +'[ + {"name":"Critical","color":"#E24B4A","sla_hours":4, "sms_alert":true, "bypass_quiet_hours":true, "sort_order":0}, + {"name":"High", "color":"#EF9F27","sla_hours":24, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":1}, + {"name":"Medium", "color":"#378ADD","sla_hours":72, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":2}, + {"name":"Low", "color":"#888780","sla_hours":168,"sms_alert":false,"bypass_quiet_hours":false,"sort_order":3} +]', + +'[ + {"title":"HVAC filter replacement","equipment_category":"HVAC", "frequency_days":90, "assigned_role":"technician","estimated_minutes":20}, + {"title":"Fire system inspection", "equipment_category":"Safety", "frequency_days":365,"assigned_role":"technician","estimated_minutes":120}, + {"title":"UPS battery test", "equipment_category":"IT Infrastructure","frequency_days":90,"assigned_role":"technician","estimated_minutes":30} +]'), + +-- ── Healthcare ───────────────────────────────────────────────────────────── +('hospital', 'Healthcare', + +'[ + {"name":"Hospital", "icon":"hospital", "expected_depth":0}, + {"name":"Wing", "icon":"layout", "expected_depth":1}, + {"name":"Department", "icon":"grid", "expected_depth":2}, + {"name":"Room", "icon":"home", "expected_depth":3} +]', + +'[ + {"name":"Medical Equipment", "icon":"activity", "default_pm_interval_days":90}, + {"name":"HVAC", "icon":"wind", "default_pm_interval_days":30}, + {"name":"Plumbing", "icon":"pipe", "default_pm_interval_days":90}, + {"name":"Electrical", "icon":"zap", "default_pm_interval_days":90}, + {"name":"Gas Systems", "icon":"flame", "default_pm_interval_days":30}, + {"name":"Sterile Supply", "icon":"package", "default_pm_interval_days":7}, + {"name":"Lifts / Elevators", "icon":"arrow-up-down","default_pm_interval_days":30}, + {"name":"IT Infrastructure", "icon":"server", "default_pm_interval_days":30} +]', + +'[ + {"name":"Clinical Equipment Fault","icon":"activity", "default_severity":"critical"}, + {"name":"Safety", "icon":"alert-triangle","default_severity":"critical"}, + {"name":"Compliance / Regulatory", "icon":"file-check", "default_severity":"high"}, + {"name":"Infection Control", "icon":"shield", "default_severity":"critical"}, + {"name":"Preventive", "icon":"clock", "default_severity":"low"}, + {"name":"General Maintenance", "icon":"tool", "default_severity":"medium"} +]', + +'[ + {"name":"Critical","color":"#E24B4A","sla_hours":1, "sms_alert":true, "bypass_quiet_hours":true, "sort_order":0}, + {"name":"High", "color":"#EF9F27","sla_hours":4, "sms_alert":true, "bypass_quiet_hours":false,"sort_order":1}, + {"name":"Medium", "color":"#378ADD","sla_hours":24, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":2}, + {"name":"Low", "color":"#888780","sla_hours":72, "sms_alert":false,"bypass_quiet_hours":false,"sort_order":3} +]', + +'[ + {"title":"Medical device calibration","equipment_category":"Medical Equipment","frequency_days":90,"assigned_role":"technician","estimated_minutes":120}, + {"title":"Gas pressure check", "equipment_category":"Gas Systems", "frequency_days":30,"assigned_role":"technician","estimated_minutes":30}, + {"title":"Steriliser validation", "equipment_category":"Sterile Supply", "frequency_days":7, "assigned_role":"technician","estimated_minutes":60}, + {"title":"Emergency lighting test", "equipment_category":"Electrical", "frequency_days":30,"assigned_role":"technician","estimated_minutes":20}, + {"title":"Lift monthly inspection", "equipment_category":"Lifts / Elevators","frequency_days":30,"assigned_role":"technician","estimated_minutes":60} +]'); diff --git a/scripts/init-progres.sql b/scripts/init-progres.sql new file mode 100644 index 0000000..1377074 --- /dev/null +++ b/scripts/init-progres.sql @@ -0,0 +1,6 @@ +-- gen_random_uuid() for all PKs +CREATE EXTENSION IF NOT EXISTS pgcrypto; +-- fuzzy search on equipment/issue names +CREATE EXTENSION IF NOT EXISTS pg_trgm; +-- exclusion constraints for PM schedules +CREATE EXTENSION IF NOT EXISTS btree_gist; From a430f2fc3d6668ae1f20098254dff5c68a66608f Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 11 Apr 2026 22:29:45 +0100 Subject: [PATCH 02/18] refactor(docker-compose): clean up --- docker-compose.dev.yml | 109 +++++++++------------------------------- docker-compose.prod.yml | 45 ++--------------- 2 files changed, 30 insertions(+), 124 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c32016e..7377a5b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -3,21 +3,15 @@ # 2. migrate — runs all SQL migrations, then exits # 3. hasura — GraphQL engine starts after schema exists # 4. 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: - # The only persistent data store. All tables live here. - # Data survives container restarts via the postgres_data volume. postgres: image: postgres:16-alpine - # Container name is what other services use to reach this host container_name: operafix_postgres - # unless-stopped: restarts automatically if it crashes, - # but respects `docker compose stop` / `docker compose down` restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-operafix} @@ -35,25 +29,22 @@ services: ports: - "5432:5432" healthcheck: - # pg_isready polls until Postgres is actually accepting connections. - # Other services use `condition: service_healthy` to wait for this. + # pg_isready polls until Postgres is actually accepting connections + # Other services use `condition: service_healthy` to wait for this # Without healthchecks, migrate might run before Postgres is ready - # and fail with "connection refused". + # and fail with "connection refused" test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-operafix} -d ${POSTGRES_DB:-operafix}", ] - # check every 5 seconds interval: 5s - # fail the check if no response within 5s timeout: 5s - # mark unhealthy after 10 consecutive failures retries: 10 networks: - operafix_net - # A one-shot service: runs all SQL migration files in order, then exits. + # A one-shot service: runs all SQL migration files in order, then exits # It is NOT a long-running server # # Migration files live in hasura/migrations/ and are named: @@ -62,22 +53,17 @@ services: # # golang-migrate reads the numeric prefix to determine order. # It tracks which migrations have already run in a schema_migrations - # table inside Postgres, so re-running is safe (idempotent). + # table inside Postgres, so re-running is safe (idempotent) migrate: image: migrate/migrate:v4.17.0 container_name: operafix_migrate - # restart: "no" — this service is expected to exit after running. - # Docker will not try to restart it. If it fails (non-zero exit), - # docker compose will report the error and halt dependent services. restart: "no" depends_on: postgres: - # Wait until postgres passes its healthcheck before starting. - # Without this, migrate would try to connect before Postgres is ready. + # Wait until postgres passes its healthcheck before starting + # Without this, migrate would try to connect before Postgres is ready condition: service_healthy volumes: - # Mount migration files into the container at /migrations. - # Read-only — migrate only reads these files, never writes them. - ./hasura/migrations:/migrations:ro command: # -path: where to find the migration files inside the container @@ -90,19 +76,9 @@ services: networks: - operafix_net - # Sits in front of Postgres and exposes an instant GraphQL API. - # Handles: queries, mutations, real-time subscriptions, row-level - # permissions, event triggers (calls Go API when DB rows change), - # and actions (proxies complex operations to the Go API). - # Dev: plain upstream image, metadata mounted as a volume so you - # can change permissions/relationships without rebuilding. - # Prod: `docker build --target production ./hasura` bakes metadata - # into the image — no volume mounts needed at runtime. hasura: build: - # Docker build context: the hasura/ directory context: ./hasura - # hasura/Dockerfile dockerfile: Dockerfile # Use the development stage (plain upstream image) target: development @@ -112,56 +88,34 @@ services: postgres: condition: service_healthy migrate: - # Wait until all SQL migrations have run successfully. - # Hasura introspects the DB schema on boot — tables must exist first. + # Wait until all SQL migrations have run successfully + # Hasura introspects the DB schema on boot — tables must exist first condition: service_completed_successfully environment: - # Connection string Hasura uses to talk to Postgres. - # >- is YAML block scalar: joins lines into one string without newline. + # Connection string Hasura uses to talk to Postgres + # >- is YAML block scalar: joins lines into one string without newline # Required here 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} - # Admin secret: required to access the Hasura console and make - # schema/metadata changes. Also used by the Go API to make - # privileged queries that bypass row-level permissions. - # Generate a strong one with: openssl rand -hex 16 + # TODO: change the secrets hehe HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme_admin_secret_32chars} - # JWT secret: Hasura validates every incoming request's JWT against this. - # Must match JWT_SECRET in the api service — they share the same HS256 - # key so tokens issued by Go are accepted by Hasura automatically. - # The JSON wrapper format is required by Hasura (not a plain string). HASURA_GRAPHQL_JWT_SECRET: >- {"type":"HS256","key":"${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long}"} - # Console: browser UI at http://localhost:8080/console - # Disable in production — it's a dev/admin tool only. HASURA_GRAPHQL_ENABLE_CONSOLE: "true" - # Dev mode: adds detailed error messages to GraphQL responses. - # Never expose these to end users — disable in production. HASURA_GRAPHQL_DEV_MODE: "true" - # Log types emitted to stdout. In production remove query-log - # (very verbose) and keep startup + http-log only. HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log,websocket-log,query-log - # Directory Hasura reads metadata from on startup. - # Must match the volume mount target path below. HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata - # Base URL for Hasura Actions and Event Triggers. # 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. + # "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. - # Keep low locally. Raise for production based on observed load. + # Max simultaneous Postgres connections Hasura will hold open + # Keep low locally. Raise for production based on observed load HASURA_GRAPHQL_PG_CONNECTIONS: "10" - # Transaction isolation level for all Hasura DB operations. HASURA_GRAPHQL_TX_ISOLATION: serializable volumes: - # Mount local metadata into the container so changes (permissions, - # relationships, actions) apply on restart without rebuilding. - # In production this volume is absent — metadata is baked into the image. - ./hasura/metadata:/hasura-metadata:ro ports: - # Hasura console + GraphQL API at http://localhost:8080 - # frontend talks to /v1/graphql on this port. - "8080:8080" healthcheck: # /healthz returns 200 only when Hasura is fully booted @@ -170,8 +124,8 @@ services: 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. + # 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 @@ -186,7 +140,7 @@ services: # # /hasura — Action + Event Trigger webhook handlers # # # # In development, `air` watches for .go file changes and rebuilds - # # the binary automatically — no need to restart the container. + # # the binary automatically — no need to restart the container # api: # build: # context: ./api @@ -254,24 +208,22 @@ services: # on file save — no full page reload. # # In production: `docker build --target production ./front` compiles - # static assets and serves them via Caddy. + # static assets and serves them via Caddy front: build: context: ./front dockerfile: Dockerfile - # Runs Vite dev server with HMR target: development container_name: operafix_front restart: unless-stopped depends_on: hasura: - # Frontend needs Hasura ready before it can serve meaningful content. - # Without this the app boots but every GraphQL query fails on load. + # Frontend needs Hasura ready before it can serve meaningful content + # Without this the app boots but every GraphQL query fails on load condition: service_healthy environment: # VITE_* prefix is mandatory — Vite only injects variables with this - # prefix into the browser bundle. Unprefixed vars stay server-side. - + # prefix into the browser bundle. Unprefixed vars stay server-side # HTTP endpoint for GraphQL queries and mutations VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL:-http://localhost:8080/v1/graphql} # WebSocket endpoint for real-time subscriptions @@ -280,12 +232,8 @@ services: # Go API base URL for auth, file uploads, QR generation VITE_API_URL: ${VITE_API_URL:-http://localhost:8081} volumes: - # Mount front/ source so Vite's file watcher picks up changes. + # Mount front/ source so Vite's file watcher picks up changes - ./front:/app:delegated - # Named volume for node_modules — essential on macOS and Windows. - # Without this, Docker mounts your host's node_modules (compiled for - # your OS) into the Linux container, breaking native binary addons. - # The named volume holds a Linux-native copy built inside the container. - front_node_modules:/app/node_modules ports: - "5173:5173" @@ -293,18 +241,11 @@ services: - operafix_net volumes: - # Your entire database. Deleting this = dropping all tables and data. + # Our entire database postgres_data: - # Go module cache. Safe to delete — re-downloaded on next build. + # Go module cache. Safe to delete — re-downloaded on next build go_mod_cache: - # Linux-native node_modules for the frontend. Safe to delete — - # npm ci reinstalls on next build (takes ~30s). front_node_modules: -# Networks -# All services share one bridge network so they can reach each other -# by service name (postgres, hasura, api, front). -# Nothing on this network is externally reachable unless explicitly -# mapped via `ports:` above. networks: operafix_net: driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bfbf8ac..1bfb280 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,16 +1,3 @@ -# Usage: -# docker compose -f docker-compose.prod.yml up -d -# -# Before running: -# 1. All VITE_* vars must be set — they are baked into the JS -# bundle at build time. No fallback defaults on purpose: -# Docker will refuse to start if they are missing. -# 2. All secrets must be set in your server .env or secrets manager. -# Never use the changeme_* placeholders in production. -# 3. Generate strong secrets: -# openssl rand -hex 32 ← for JWT_SECRET -# openssl rand -hex 16 ← for HASURA_ADMIN_SECRET -# # Key differences from docker-compose.dev.yml: # - All services build their production stage (compiled binaries, # static assets — no hot-reload tooling) @@ -34,8 +21,6 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro - # No `ports:` — Postgres is internal only in production. - # Exposing 5432 to the internet is a critical security risk. healthcheck: test: [ @@ -54,8 +39,8 @@ services: networks: - operafix_net - # Runs pending migrations on every deploy, then exits. - # Safe to run repeatedly — already-applied migrations are skipped. + # Runs pending migrations on every deploy, then exits + # Safe to run repeatedly — already-applied migrations are skipped migrate: image: migrate/migrate:v4.17.0 container_name: operafix_migrate @@ -72,8 +57,6 @@ services: networks: - operafix_net - # Production image has metadata baked in (no volume mount). - # Console and dev mode are disabled. hasura: build: context: ./hasura @@ -92,16 +75,11 @@ services: postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix} # no fallback — must be set HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} - HASURA_GRAPHQL_JWT_SECRET: >- {"type":"HS256","key":"${JWT_SECRET}"} # no fallback — must be set - # Disabled in production — exposes schema and admin API HASURA_GRAPHQL_ENABLE_CONSOLE: "false" - # Disabled in production — leaks error internals to clients HASURA_GRAPHQL_DEV_MODE: "false" - # query-log and websocket-log removed — too verbose for production HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log - # Metadata is baked into the production image — no mount needed HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata ACTION_BASE_URL: ${ACTION_BASE_URL:-http://api:8080} # Raise connection pool for production load @@ -110,7 +88,7 @@ services: # No volume mount — metadata ships inside the production image ports: # Only expose internally unless you're putting Hasura behind - # a reverse proxy. If using Caddy/Traefik in front, remove this. + # a reverse proxy - "8080:8080" healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"] @@ -127,8 +105,6 @@ services: networks: - operafix_net - # Production stage: static binary in a scratch image. - # No shell, no package manager, ~10MB image size. api: build: context: ./api @@ -181,19 +157,12 @@ services: networks: - operafix_net - # Production stage: npm run build → static assets served by Caddy. - # Caddy handles: gzip/zstd compression, aggressive asset caching, - # SPA routing (try_files → index.html), security headers. front: build: context: ./front dockerfile: Dockerfile target: production args: - # VITE_* vars are baked into the JS bundle at build time. - # These must be your real public-facing URLs — not localhost, - # not internal Docker hostnames. No fallbacks intentionally: - # Docker refuses to build if these are unset. VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL} VITE_GRAPHQL_WS: ${VITE_GRAPHQL_WS} VITE_API_URL: ${VITE_API_URL} @@ -202,11 +171,7 @@ services: depends_on: hasura: condition: service_healthy - # No environment vars at runtime — everything was baked in at build time - # No volume mounts — Caddy serves from /srv inside the image ports: - # Caddy serves on port 80 inside the container. - # Put your hosting platform's reverse proxy or load balancer in front. - "80:80" deploy: resources: @@ -217,8 +182,8 @@ services: networks: - operafix_net -# Only postgres_data in production. -# go_mod_cache and front_node_modules are dev-only build artefacts. +# Only postgres_data in production +# go_mod_cache and front_node_modules are dev-only build artefacts volumes: postgres_data: networks: From 5780b5bb5797fc5bb055d040fd78fa8c0cc8e568 Mon Sep 17 00:00:00 2001 From: lee Date: Sat, 11 Apr 2026 22:45:26 +0100 Subject: [PATCH 03/18] refactor(dockerfile): clean up --- api/Dockerfile | 63 ++++-------------------------------- front/Caddyfile | 6 ++-- front/Dockerfile | 82 +++++------------------------------------------ hasura/Dockerfile | 81 ++++------------------------------------------ 4 files changed, 23 insertions(+), 209 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index dd7ea70..056708f 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,12 +1,4 @@ -# Stages: -# base → shared foundation (deps downloaded, tools installed) -# development → hot-reload via `air` (source mounted as volume) -# builder → compiles the static production binary -# production → minimal scratch image, binary only - # Shared foundation used by both development and builder stages. -# Installing deps here means they are layer-cached and not repeated -# in child stages — Docker only re-runs this if go.mod or go.sum change. FROM golang:1.23-alpine AS base # Install system dependencies: @@ -21,72 +13,38 @@ RUN apk add --no-cache git curl ca-certificates tzdata WORKDIR /app -# Copy only the dependency manifests first. -# Docker layer caching: if go.mod and go.sum haven't changed, the next -# RUN (go mod download) is skipped entirely — saving 20-60s per build. -# COPY . . must come AFTER this, not before. COPY go.mod go.sum ./ -# Download all dependencies declared in go.mod into the module cache. -# `go mod verify` checks that downloaded files match their expected checksums. -# This catches any supply-chain tampering or corrupted downloads. RUN go mod download && go mod verify - # Used by docker-compose.dev.yml. # Does NOT copy source code — source is mounted as a live volume at -# runtime so that file changes are visible inside the container immediately. -# `air` watches the mounted source and rebuilds the binary on every save. +# runtime so that file changes are visible inside the container immediately +# `air` watches the mounted source and rebuilds the binary on every save FROM base AS development # Install `air` — a file watcher that rebuilds and restarts the Go -# binary whenever a .go file changes. Equivalent to nodemon for Go. -# Pinned to a specific version for reproducibility. # Config lives in api/.air.toml RUN go install github.com/air-verse/air@v1.52.3 -# The Go module cache from the base stage is available here. -# The source code is NOT copied — it arrives via the docker-compose volume: -# volumes: -# - ./api:/app:delegated -# This means any file save on your host triggers an automatic rebuild -# inside the container without restarting the container itself. - EXPOSE 8080 # air reads its config from .air.toml in the working directory (/app), # which is satisfied by the volume mount of ./api at runtime. CMD ["air", "-c", ".air.toml"] -# Compiles the production binary. This stage is never deployed — -# it exists only to produce the binary that the production stage copies. -# Running the compiler in golang:alpine means the production image -# does not need Go installed at all. +# Compiles the production binary. This stage is never deployed +# it exists only to produce the binary that the production stage copies FROM base AS builder -# Now copy the full source tree into the builder. -# This layer is invalidated whenever any source file changes, -# which is fine — the builder stage is only run for production builds. COPY . . # Build metadata injected via ldflags — set by your CI/CD pipeline. -# Available in code via: var Version, Commit, BuildTime string (in main.go) # Useful for /health or /version endpoints to confirm what is deployed. ARG VERSION=dev ARG COMMIT=unknown ARG BUILD_TIME=unknown -# Compile flags explained: -# CGO_ENABLED=0 — disable C bindings → pure Go binary with no -# libc dependency → runs on scratch (no libc present) -# GOOS=linux — always target Linux regardless of build machine OS -# GOARCH=amd64 — target 64-bit x86 (change to arm64 for Apple Silicon servers) -# -trimpath — strip local filesystem paths from the binary -# (prevents leaking your dev machine's directory structure -# in stack traces and error messages) -# -ldflags "-s -w" — -s strips the symbol table, -w strips DWARF debug info -# together they reduce binary size by ~30% -# -X — inject build metadata as string variables at link time RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -trimpath \ -ldflags="-s -w \ @@ -96,10 +54,9 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -o /bin/operafix-api \ ./cmd/server - +# TODO: is this an overkill??? # The final deployable image. Built on `scratch` — an empty base image # with literally nothing in it: no shell, no package manager, no OS utils. -# # Attack surface: zero. If an attacker gets RCE they have no tools to # work with — no bash, no curl, no wget, no package manager. # Image size: ~15MB (binary + certs + timezone data only). @@ -114,21 +71,13 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Without this, time.LoadLocation("Europe/Lisbon") panics at runtime. # All timestamp conversions for display (UTC → location timezone) depend on this. COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo - -# Copy only the compiled binary — nothing else from the builder stage. COPY --from=builder /bin/operafix-api /operafix-api EXPOSE 8080 -# Run as a non-root user. -# scratch has no /etc/passwd so we use a numeric UID directly. -# 65532 is the conventional "nonroot" UID used by distroless images. +# 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 vs CMD: -# ENTRYPOINT — the binary that always runs (cannot be overridden without --entrypoint) -# CMD — default arguments passed to ENTRYPOINT (can be overridden) -# Using ENTRYPOINT here means `docker run operafix-api` always runs the binary. ENTRYPOINT ["/operafix-api"] diff --git a/front/Caddyfile b/front/Caddyfile index ba61092..ad565e2 100644 --- a/front/Caddyfile +++ b/front/Caddyfile @@ -10,20 +10,20 @@ file_server - # ── Cache headers ────────────────────────────────────────────── + # Cache headers # Vite fingerprints assets (main.a1b2c3.js) — safe to cache forever @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" - # index.html must never be cached — it bootstraps the SPA + # index.html must never be cached @html { path *.html } header @html Cache-Control "no-store" - # ── Security headers ─────────────────────────────────────────── + # Security headers header { X-Frame-Options "DENY" X-Content-Type-Options "nosniff" diff --git a/front/Dockerfile b/front/Dockerfile index 8eaadfe..82d5c9c 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -5,38 +5,19 @@ # production → Caddy serving the static build from /srv # Shared foundation — installs node_modules once, cached for child stages. -# Using the Alpine variant keeps the image small (~170MB vs ~1GB for full node). FROM node:20-alpine AS base WORKDIR /app - -# Copy manifests before source code. -# Docker layer caching: if package.json and package-lock.json haven't -# changed, the npm ci layer is reused — saving 30-120s per build. -# COPY . . must come AFTER this block in the builder stage. COPY package.json package-lock.json ./ -# npm ci (clean install) vs npm install: -# npm ci — installs exactly what package-lock.json specifies. -# Fails if lock file is out of sync with package.json. -# Faster and reproducible — the right choice for Docker. -# npm install — resolves and may update versions, not reproducible. -# -# --prefer-offline: use the npm cache before hitting the registry. -# Speeds up builds when the same packages were previously downloaded. +# TODO: could we avoid peer-deps?? 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 -# -# The second volume (front_node_modules) is critical on macOS/Windows: -# it shadows the host's node_modules with a Linux-native copy built -# inside this image layer. Without it, native binary addons compiled -# for macOS would be mounted into the Linux container and crash. FROM base AS development # node_modules from the base stage are already at /app/node_modules. @@ -44,57 +25,26 @@ FROM base AS development EXPOSE 5173 -# --host 0.0.0.0 — Vite binds to all network interfaces inside the -# container, not just 127.0.0.1. Required for Docker's port forwarding -# to work: without this, Vite listens only on the container's loopback -# and the mapped port 5173 on your host receives no traffic. CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] - -# ── Stage 3: builder ──────────────────────────────────────────────── -# Compiles the React app into optimised static assets. -# Output goes to /app/dist — copied into the production stage. +# Stage 3: builder +# Compiles the React app into optimised static assets FROM base AS builder -# Copy the full source tree into the builder. -# node_modules are already present from the base stage. COPY . . -# VITE_* environment variables are baked into the JavaScript bundle -# at build time by Vite's define plugin. They are NOT available at -# runtime — Caddy serves static files and has no concept of env vars. -# -# These must be your real public-facing URLs (not localhost or internal -# Docker hostnames). They are injected via docker-compose build args: -# args: -# VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL} -# -# No default values — if unset, the build fails loudly rather than -# silently baking "undefined" into the bundle. +# No default values — if unset, the build fails loudly rather than! ARG VITE_GRAPHQL_URL ARG VITE_GRAPHQL_WS ARG VITE_API_URL -# Promote build args to environment variables so Vite can read them. -# ARG values are not automatically visible as ENV — this step is required. -ENV VITE_GRAPHQL_URL=$VITE_GRAPHQL_URL \ - VITE_GRAPHQL_WS=$VITE_GRAPHQL_WS \ - VITE_API_URL=$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. -# Output: /app/dist/ -# index.html — entry point (never cached) -# assets/index.abc123.js — fingerprinted JS bundle (cached 1 year) -# assets/index.abc123.css — fingerprinted CSS (cached 1 year) -# + any other static assets from /public RUN npm run build - -# ── Stage 4: production ───────────────────────────────────────────── -# Serves the compiled static assets using Caddy. -# Caddy handles: gzip/zstd compression, cache headers, SPA routing, -# and security headers — all configured in the Caddyfile. -# # Why Caddy over nginx: # - Single-line compression (zstd + gzip) vs nginx's gzip_* block # - Correct mime types out of the box (no mime.types file needed) @@ -102,28 +52,12 @@ RUN npm run build # - Strips Server header by default FROM caddy:2.8-alpine AS production -# Copy the Caddyfile from the build context (front/Caddyfile). -# This configures: -# - SPA fallback (try_files → index.html for React Router) -# - Aggressive caching on fingerprinted assets (1 year, immutable) -# - No-cache on index.html (entry point must always be fresh) -# - Security headers (X-Frame-Options, X-Content-Type-Options, etc.) -# - gzip + zstd compression COPY Caddyfile /etc/caddy/Caddyfile -# Copy the compiled static assets from the builder stage. -# Caddy is configured to serve from /srv — this is Caddy's conventional -# static file root (matches the default Caddy image expectation). -# Only the dist/ contents are copied — no node_modules, no source files. +# 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. -# Mapped to host port 80 in docker-compose.prod.yml. EXPOSE 80 -# Caddy runs as root inside the container by default. -# This is acceptable for a static file server with no write access to -# the filesystem — Caddy drops privileges after binding to port 80. -# If your platform requires non-root, use caddy:2.8-alpine with -# `USER caddy` and switch to port 8080 (no privilege needed above 1024). CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/hasura/Dockerfile b/hasura/Dockerfile index 6b1a331..7d95d78 100644 --- a/hasura/Dockerfile +++ b/hasura/Dockerfile @@ -1,72 +1,21 @@ -# Stages: -# development → plain upstream image, config via environment variables, -# metadata mounted as a volume at runtime -# production → same upstream image with metadata baked in, -# fully self-contained — no volume mounts at runtime -# # Responsibility boundary: # Hasura owns: metadata (permissions, relationships, actions, # event triggers, remote schemas) # Hasura does NOT own: database schema # +# TODO: (THIS IS NOT THE CASE FOR NOW!) # Database schema is managed by golang-migrate (hasura/migrations/). # Migrations run before Hasura starts — Hasura introspects the existing # tables and applies metadata on top. This separation means schema # changes go through SQL files with proper up/down rollbacks, not # through Hasura's console. -# -# Usage: -# Dev: docker compose -f docker-compose.dev.yml up hasura -# (builds the development stage — effectively the plain image) -# Prod: docker compose -f docker-compose.prod.yml up hasura -# (builds the production stage — metadata baked in) -# -# Metadata workflow: -# 1. Make changes in the Hasura console (http://localhost:8080) -# 2. Export: hasura metadata export \ -# --endpoint http://localhost:8080 \ -# --admin-secret -# 3. Commit the updated hasura/metadata/ directory -# 4. On next prod deploy, `docker build --target production ./hasura` -# bakes the new metadata into the image automatically -# ═══════════════════════════════════════════════════════════════════ - -# Uses the plain upstream Hasura image with no modifications. -# -# All configuration is injected via environment variables at runtime -# (see docker-compose.dev.yml — the `hasura` service environment block). -# -# Metadata is NOT baked in — it is mounted as a read-only volume: -# volumes: -# - ./hasura/metadata:/hasura-metadata:ro -# -# This means you can edit permissions, relationships, and actions in -# the Hasura console and export them to your local hasura/metadata/ -# directory without rebuilding the image. Changes are picked up on -# the next container restart. -# -# Why a Dockerfile for development if the image is unchanged? -# Consistency — all four services (postgres excepted) define both -# a development and production stage. docker-compose.dev.yml always -# uses `target: development`. This makes the pattern uniform and -# means `docker build --target development ./hasura` always works. FROM hasura/graphql-engine:v2.40.0 AS development -# No additional layers — the upstream image is used as-is. -# Environment variables and volume mounts handle everything at runtime. - +# No additional layers — the upstream image is used as-is +# Environment variables and volume mounts handle everything at runtime EXPOSE 8080 -# Same upstream image, but with metadata copied directly into the image. -# -# Why bake metadata into the image? -# In production there are no volume mounts. The container must be -# fully self-contained — deploy the image and it works with no -# external files required. This also means every production deployment -# is reproducible: the exact metadata that was tested is the exact -# metadata that ships. -# # What is metadata? # Everything Hasura knows about your data that is NOT the SQL schema: # - Which tables are tracked (visible via GraphQL) @@ -75,37 +24,19 @@ EXPOSE 8080 # - Actions (HTTP endpoints proxied to the Go API) # - Event triggers (Go API called when DB rows change) # - Remote schemas (if any external GraphQL APIs are stitched in) -# # What metadata does NOT include: # - SQL schema (tables, columns, indexes, FKs) — that is in migrations/ # - Data (rows) — that is seeded separately FROM hasura/graphql-engine:v2.40.0 AS production -# Copy the entire metadata directory from the build context (hasura/). -# Build context is ./hasura so this copies hasura/metadata/ → /hasura-metadata/ -# -# Contents after a `hasura metadata export`: -# /hasura-metadata/ -# databases/ -# databases.yaml — database connection config -# default/ -# tables/ -# tables.yaml — tracked tables + relationships + permissions -# actions.yaml — custom action definitions -# actions.graphql — GraphQL types for actions -# cron_triggers.yaml — scheduled triggers (if any) -# remote_schemas.yaml — stitched remote GraphQL APIs (if any) -# version.yaml — metadata format version COPY metadata/ /hasura-metadata/ -# Tell Hasura where to find the metadata on startup. -# Must match the directory we copied to above. -# Hasura applies this metadata automatically when the container starts — -# no manual `hasura metadata apply` step required. +# Tell Hasura where to find the metadata on startup +# Must match the directory we copied to above +# Hasura applies this metadata automatically when the container starts ENV HASURA_GRAPHQL_METADATA_DIR=/hasura-metadata EXPOSE 8080 - # No CMD — Hasura's upstream image already defines the correct entrypoint. # The graphql-engine binary starts automatically with the env vars # injected by docker-compose.prod.yml. From d4f5315ae15d7c31ceff77ac3086f130ea4f7efa Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 12 Apr 2026 21:39:32 +0100 Subject: [PATCH 04/18] feat(api): add go mod for api + fix air.toml to have live update when developing + add token base tables for authentication --- .gitignore | 5 +- api/.air.toml | 9 +- api/go.mod | 17 ++- docker-compose.dev.yml | 144 +++++++++--------- docker-compose.prod.yml | 2 + .../tables/public_magic_link_tokens.yaml | 7 + .../default/tables/public_refresh_tokens.yaml | 7 + .../default/tables/public_users.yaml | 14 ++ .../databases/default/tables/tables.yaml | 2 + hasura/migrations/017_auth_tokens.up.sql | 67 ++++++++ 10 files changed, 194 insertions(+), 80 deletions(-) create mode 100644 hasura/metadata/databases/default/tables/public_magic_link_tokens.yaml create mode 100644 hasura/metadata/databases/default/tables/public_refresh_tokens.yaml create mode 100644 hasura/migrations/017_auth_tokens.up.sql diff --git a/.gitignore b/.gitignore index 494d016..67b73d5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ Thumbs.db test-results/ playwright-report/ playwright/.cache/ -verify_ui.py \ No newline at end of file +verify_ui.py + +# Air golang +tmp/ diff --git a/api/.air.toml b/api/.air.toml index 4a069e3..ed4bd69 100644 --- a/api/.air.toml +++ b/api/.air.toml @@ -1,12 +1,9 @@ -# api/.air.toml — hot-reload config for Go service -# Paths are relative to the api/ directory (the build context and volume mount root) - root = "." -tmp_dir = "/tmp/air" +tmp_dir = "tmp" [build] - cmd = "go build -o /tmp/air/mantis-api ./cmd/server" - bin = "/tmp/air/mantis-api" + cmd = "go build -o tmp/operafix-api ./cmd/server" + bin = "tmp/operafix-api" include_ext = ["go", "toml", "yaml"] exclude_dir = ["vendor", "testdata"] delay = 500 diff --git a/api/go.mod b/api/go.mod index f2c328b..6220625 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,3 +1,18 @@ module api -go 1.24.4 +go 1.23.12 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.6.0 + golang.org/x/crypto v0.24.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7377a5b..cedb7e5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -130,78 +130,78 @@ services: networks: - operafix_net - # # Handles everything Hasura cannot do directly: - # # /auth — JWT issuance, refresh tokens, magic-link email login - # # /upload — presigned Cloudflare R2 URLs for photo uploads - # # /qr — QR code + printable label PDF generation - # # /onboard — industry template seeding on company signup - # # /notify — email (Postmark) + SMS (Twilio) dispatch - # # /cron — SLA monitor, PM scheduler, nightly analytics aggregation - # # /hasura — Action + Event Trigger webhook handlers - # # - # # In development, `air` watches for .go file changes and rebuilds - # # the binary automatically — no need to restart the container - # 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. - # # It makes privileged GraphQL calls to Hasura during onboarding - # # and would fail immediately if Hasura isn't accepting requests. - # condition: service_healthy - # environment: - # # Direct Postgres connection string for the Go service. - # # Used for operations that bypass Hasura: auth queries, - # # cron jobs writing analytics data, migrations during tests. - # 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" resolves to the hasura container on the Docker network. - # HASURA_ENDPOINT: http://hasura:8080/v1/graphql - # HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme_admin_secret_32chars} - # # JWT signing key — must be identical to Hasura's JWT secret above. - # # Go signs tokens with this key; Hasura verifies them with the same key. - # JWT_SECRET: ${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long} - # # 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 - # # debug | info | warn | error - # 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 - # # Named volume for the Go module cache ($GOPATH/pkg/mod). - # # Avoids re-downloading all dependencies on every `docker compose build`. - # - go_mod_cache:/root/go/pkg/mod - # ports: - # # (8080 is already used by Hasura, so API uses 8081 on the host) - # - "8081:8080" - # networks: - # - operafix_net + # Handles everything Hasura cannot do directly: + # /auth — JWT issuance, refresh tokens, magic-link email login + # /upload — presigned Cloudflare R2 URLs for photo uploads + # /qr — QR code + printable label PDF generation + # /cron — SLA monitor, PM scheduler, nightly analytics aggregation + # /hasura — Action + Event Trigger webhook handlers + # /onboard — [LATER] industry template seeding on company signup + # /notify — [LATER] email (Postmark) + SMS (Twilio) dispatch + # + # In development, `air` watches for .go file changes and rebuilds + # the binary automatically — no need to restart the container + 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. + # It makes privileged GraphQL calls to Hasura during onboarding + # and would fail immediately if Hasura isn't accepting requests. + condition: service_healthy + environment: + # Direct Postgres connection string for the Go service. + # Used for operations that bypass Hasura: auth queries, + # cron jobs writing analytics data, migrations during tests. + 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" resolves to the hasura container on the Docker network. + HASURA_ENDPOINT: http://hasura:8080/v1/graphql + HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme_admin_secret_32chars} + # JWT signing key — must be identical to Hasura's JWT secret above. + # Go signs tokens with this key; Hasura verifies them with the same key. + JWT_SECRET: ${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long} + # 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 + # debug | info | warn | error + 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 + # Named volume for the Go module cache ($GOPATH/pkg/mod). + # Avoids re-downloading all dependencies on every `docker compose build`. + - go_mod_cache:/root/go/pkg/mod + ports: + # (8080 is already used by Hasura, so API uses 8081 on the host) + - "8081:8080" + networks: + - operafix_net # Serves the React app with Vite's dev server in development. # HMR (Hot Module Replacement) means the browser updates instantly diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1bfb280..23e0ce2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -171,6 +171,8 @@ services: depends_on: hasura: condition: service_healthy + api: + condition: service_started ports: - "80:80" deploy: diff --git a/hasura/metadata/databases/default/tables/public_magic_link_tokens.yaml b/hasura/metadata/databases/default/tables/public_magic_link_tokens.yaml new file mode 100644 index 0000000..c6b6db9 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_magic_link_tokens.yaml @@ -0,0 +1,7 @@ +table: + name: magic_link_tokens + schema: public +object_relationships: + - name: user + using: + foreign_key_constraint_on: user_id diff --git a/hasura/metadata/databases/default/tables/public_refresh_tokens.yaml b/hasura/metadata/databases/default/tables/public_refresh_tokens.yaml new file mode 100644 index 0000000..99f8537 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_refresh_tokens.yaml @@ -0,0 +1,7 @@ +table: + name: refresh_tokens + schema: public +object_relationships: + - name: user + using: + foreign_key_constraint_on: user_id diff --git a/hasura/metadata/databases/default/tables/public_users.yaml b/hasura/metadata/databases/default/tables/public_users.yaml index a1d2657..413e532 100644 --- a/hasura/metadata/databases/default/tables/public_users.yaml +++ b/hasura/metadata/databases/default/tables/public_users.yaml @@ -44,6 +44,13 @@ array_relationships: table: name: locations schema: public + - name: magic_link_tokens + using: + foreign_key_constraint_on: + column: user_id + table: + name: magic_link_tokens + schema: public - name: maintenance_actions using: foreign_key_constraint_on: @@ -65,3 +72,10 @@ array_relationships: table: name: preventive_schedules schema: public + - name: refresh_tokens + using: + foreign_key_constraint_on: + column: user_id + table: + name: refresh_tokens + schema: public diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 15f9245..3aac313 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -10,10 +10,12 @@ - "!include public_issues.yaml" - "!include public_location_types.yaml" - "!include public_locations.yaml" +- "!include public_magic_link_tokens.yaml" - "!include public_maintenance_actions.yaml" - "!include public_notifications_log.yaml" - "!include public_parts_used.yaml" - "!include public_preventive_schedules.yaml" - "!include public_preventive_tasks.yaml" +- "!include public_refresh_tokens.yaml" - "!include public_severity_levels.yaml" - "!include public_users.yaml" diff --git a/hasura/migrations/017_auth_tokens.up.sql b/hasura/migrations/017_auth_tokens.up.sql new file mode 100644 index 0000000..f33cdec --- /dev/null +++ b/hasura/migrations/017_auth_tokens.up.sql @@ -0,0 +1,67 @@ +-- 017_auth_tokens.up.sql +-- Storage for refresh tokens and magic-link tokens. +-- We chose Postgres over Redis for simplicity — at this scale the +-- query load on these tables is negligible. +-- +-- Security model: +-- - Raw tokens are NEVER stored — only their SHA-256 hashes. +-- - Refresh tokens are rotated on every use (old revoked, new issued). +-- - Magic-link tokens are single-use and expire after 15 minutes. +-- - A scheduled cleanup job should purge expired rows periodically. + +-- ── Refresh tokens ──────────────────────────────────────────────── +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + + -- SHA-256 hex hash of the raw token sent to the client. + -- Never store the raw token — if this table is compromised, + -- hashes cannot be reversed to valid tokens. + token_hash TEXT NOT NULL UNIQUE, + + expires_at TIMESTAMPTZ NOT NULL, + + -- Set on logout or when the token is rotated (superseded by a new one). + -- NULL means the token is still active. + revoked_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Look up a token by its hash on every refresh request +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens (token_hash); + +-- Find all active tokens for a user (used on logout to revoke all) +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id) + WHERE revoked_at IS NULL; + +-- Cleanup job queries this directly — no partial index needed: +-- DELETE FROM refresh_tokens +-- WHERE expires_at < NOW() - INTERVAL '30 days' +-- OR revoked_at < NOW() - INTERVAL '30 days' + + +-- ── Magic link tokens ───────────────────────────────────────────── +CREATE TABLE magic_link_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + + -- SHA-256 hex hash of the raw token embedded in the email link. + token_hash TEXT NOT NULL UNIQUE, + + -- Magic links expire after 15 minutes — set at insert time by the app. + expires_at TIMESTAMPTZ NOT NULL, + + -- Set when the token is consumed. NULL = not yet used. + -- A used token cannot be reused — prevents replay attacks. + used_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Look up a token by its hash when the user clicks the link +CREATE INDEX idx_magic_link_tokens_hash ON magic_link_tokens (token_hash); + +-- Cleanup job queries this directly — no partial index needed: +-- DELETE FROM magic_link_tokens +-- WHERE used_at IS NOT NULL OR expires_at < NOW() - INTERVAL '1 day' From bbe71ec073b1b90409b33843c9e7f650690dc984 Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 12 Apr 2026 21:47:13 +0100 Subject: [PATCH 05/18] chore(front): fix rebsae conflicts --- {src => front/src}/pages/Docs/ComponentsDocs.tsx | 0 {src => front/src}/pages/Docs/NextPhaseDocs.tsx | 0 {src => front/src}/pages/Docs/SchemaDocs.tsx | 0 {src => front/src}/pages/Docs/ThemingDocs.tsx | 0 {src => front/src}/pages/Docs/UserJourneyDocs.tsx | 0 {src => front/src}/pages/Docs/WorkflowDocs.tsx | 0 {src => front/src}/pages/Docs/index.tsx | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {src => front/src}/pages/Docs/ComponentsDocs.tsx (100%) rename {src => front/src}/pages/Docs/NextPhaseDocs.tsx (100%) rename {src => front/src}/pages/Docs/SchemaDocs.tsx (100%) rename {src => front/src}/pages/Docs/ThemingDocs.tsx (100%) rename {src => front/src}/pages/Docs/UserJourneyDocs.tsx (100%) rename {src => front/src}/pages/Docs/WorkflowDocs.tsx (100%) rename {src => front/src}/pages/Docs/index.tsx (100%) diff --git a/src/pages/Docs/ComponentsDocs.tsx b/front/src/pages/Docs/ComponentsDocs.tsx similarity index 100% rename from src/pages/Docs/ComponentsDocs.tsx rename to front/src/pages/Docs/ComponentsDocs.tsx diff --git a/src/pages/Docs/NextPhaseDocs.tsx b/front/src/pages/Docs/NextPhaseDocs.tsx similarity index 100% rename from src/pages/Docs/NextPhaseDocs.tsx rename to front/src/pages/Docs/NextPhaseDocs.tsx diff --git a/src/pages/Docs/SchemaDocs.tsx b/front/src/pages/Docs/SchemaDocs.tsx similarity index 100% rename from src/pages/Docs/SchemaDocs.tsx rename to front/src/pages/Docs/SchemaDocs.tsx diff --git a/src/pages/Docs/ThemingDocs.tsx b/front/src/pages/Docs/ThemingDocs.tsx similarity index 100% rename from src/pages/Docs/ThemingDocs.tsx rename to front/src/pages/Docs/ThemingDocs.tsx diff --git a/src/pages/Docs/UserJourneyDocs.tsx b/front/src/pages/Docs/UserJourneyDocs.tsx similarity index 100% rename from src/pages/Docs/UserJourneyDocs.tsx rename to front/src/pages/Docs/UserJourneyDocs.tsx diff --git a/src/pages/Docs/WorkflowDocs.tsx b/front/src/pages/Docs/WorkflowDocs.tsx similarity index 100% rename from src/pages/Docs/WorkflowDocs.tsx rename to front/src/pages/Docs/WorkflowDocs.tsx diff --git a/src/pages/Docs/index.tsx b/front/src/pages/Docs/index.tsx similarity index 100% rename from src/pages/Docs/index.tsx rename to front/src/pages/Docs/index.tsx From 4081fd7f9784861955a3e1189bea1dee14cd00fa Mon Sep 17 00:00:00 2001 From: lee Date: Mon, 13 Apr 2026 17:50:33 +0100 Subject: [PATCH 06/18] feat(api): add authentication + docs + healthcheck routes for the api --- api/cmd/server/main.go | 149 +++++++++++ api/go.sum | 32 +++ api/internal/auth/handler.go | 285 +++++++++++++++++++++ api/internal/auth/middleware.go | 136 ++++++++++ api/internal/auth/repository.go | 332 ++++++++++++++++++++++++ api/internal/auth/service.go | 233 +++++++++++++++++ api/internal/auth/token.go | 214 ++++++++++++++++ api/internal/docs/handler.go | 432 ++++++++++++++++++++++++++++++++ api/pkg/config/config.go | 115 +++++++++ api/pkg/database/database.go | 56 +++++ 10 files changed, 1984 insertions(+) create mode 100644 api/cmd/server/main.go create mode 100644 api/go.sum create mode 100644 api/internal/auth/handler.go create mode 100644 api/internal/auth/middleware.go create mode 100644 api/internal/auth/repository.go create mode 100644 api/internal/auth/service.go create mode 100644 api/internal/auth/token.go create mode 100644 api/internal/docs/handler.go create mode 100644 api/pkg/config/config.go create mode 100644 api/pkg/database/database.go 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.sum b/api/go.sum new file mode 100644 index 0000000..ec85949 --- /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.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +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..bf1bf1b --- /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,omitempty"` +} + +// 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..d132ac0 --- /dev/null +++ b/api/internal/docs/handler.go @@ -0,0 +1,432 @@ +package docs + +import ( + "encoding/json" + "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) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + Operafix API + + + + + + + +`)) +} + +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 + "/health": map[string]any{ + "get": map[string]any{ + "tags": []string{"health"}, + "summary": "Health check", + "description": "Returns service status and build metadata. Used by Docker healthcheck and hosting platforms.", + "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"), + "example": map[string]any{ + "status": "ok", + "version": "1.0.0", + "commit": "a1b2c3d", + }, + }, + }, + }, + }, + }, + }, + + // POST /auth/login + "/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"), + }, + }, + }, + + // POST /auth/refresh + "/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 — rotation on every use.", + "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 — log in again"), + "500": errorResponse("Refresh failed"), + }, + }, + }, + + // POST /auth/logout + "/auth/logout": map[string]any{ + "post": map[string]any{ + "tags": []string{"auth"}, + "summary": "Logout", + "description": "Revokes all active refresh tokens for the user and clears the refresh token cookie. The access token remains valid until it expires (max 15 minutes).", + "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"}, + }, + }, + }, + }, + }, + }, + }, + }, + + // GET /auth/me + "/auth/me": map[string]any{ + "get": map[string]any{ + "tags": []string{"auth"}, + "summary": "Get current user", + "description": "Returns the authenticated user's profile. Requires a valid Bearer token in the Authorization header.", + "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"), + "500": errorResponse("Failed to fetch user"), + }, + }, + }, + + // POST /auth/magic-link + "/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 — even if the email is not registered — 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 (email sent if address is registered)", + "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"), + "500": errorResponse("Verification failed"), + }, + }, + }, + }, + + // Components + "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 header", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }, + "token_type": map[string]any{ + "type": "string", + "example": "Bearer", + }, + "expires_in": map[string]any{ + "type": "integer", + "description": "Access token lifetime in 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. Sent automatically by the browser on subsequent requests to /auth/refresh.", + "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 +} + From a354b8f3b0c646e7dc217e60ec6b453f7be6a6b6 Mon Sep 17 00:00:00 2001 From: lee Date: Mon, 13 Apr 2026 22:31:38 +0100 Subject: [PATCH 07/18] chore(docker): some cleanup --- api/Dockerfile | 16 ++++++---------- front/Dockerfile | 14 ++++---------- hasura/Dockerfile | 35 +++++------------------------------ 3 files changed, 15 insertions(+), 50 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 056708f..18e2a44 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -33,8 +33,7 @@ EXPOSE 8080 # which is satisfied by the volume mount of ./api at runtime. CMD ["air", "-c", ".air.toml"] -# Compiles the production binary. This stage is never deployed -# it exists only to produce the binary that the production stage copies +# compiles the production binary FROM base AS builder COPY . . @@ -54,20 +53,17 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -o /bin/operafix-api \ ./cmd/server -# TODO: is this an overkill??? # The final deployable image. Built on `scratch` — an empty base image -# with literally nothing in it: no shell, no package manager, no OS utils. +# with literally nothing in it: no shell, no package manager, no OS utils # Attack surface: zero. If an attacker gets RCE they have no tools to -# work with — no bash, no curl, no wget, no package manager. -# Image size: ~15MB (binary + certs + timezone data only). +# work with — no bash, no curl, no wget, no package manager FROM scratch AS production -# Copy TLS certificates from the builder stage. -# Without this, any HTTPS call (Postmark, Twilio, R2, Hasura over TLS) -# fails with "x509: certificate signed by unknown authority". +# copy TLS certificates from the builder stage. +# without this, any HTTPS call COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -# Copy timezone database from the builder stage. +# copy timezone database from the builder stage # Without this, time.LoadLocation("Europe/Lisbon") panics at runtime. # All timestamp conversions for display (UTC → location timezone) depend on this. COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo diff --git a/front/Dockerfile b/front/Dockerfile index 82d5c9c..176edca 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -4,13 +4,13 @@ # builder → compiles optimised static assets via `npm run build` # production → Caddy serving the static build from /srv -# Shared foundation — installs node_modules once, cached for child stages. +# shared foundation FROM node:20-alpine AS base WORKDIR /app COPY package.json package-lock.json ./ -# TODO: could we avoid peer-deps?? +# 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. @@ -20,24 +20,18 @@ RUN npm i --legacy-peer-deps # - front_node_modules:/app/node_modules FROM base AS development -# node_modules from the base stage are already at /app/node_modules. -# The source code arrives via the docker-compose volume mount at runtime. - EXPOSE 5173 CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] -# Stage 3: builder # Compiles the React app into optimised static assets FROM base AS builder COPY . . -# No default values — if unset, the build fails loudly rather than! 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 @@ -54,10 +48,10 @@ FROM caddy:2.8-alpine AS production COPY Caddyfile /etc/caddy/Caddyfile -# Only the dist/ contents are copied, we already builed it previously +# 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. +# caddy listens on port 80 inside the container. EXPOSE 80 CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/hasura/Dockerfile b/hasura/Dockerfile index 7d95d78..f33cf30 100644 --- a/hasura/Dockerfile +++ b/hasura/Dockerfile @@ -1,42 +1,17 @@ -# Responsibility boundary: -# Hasura owns: metadata (permissions, relationships, actions, -# event triggers, remote schemas) -# Hasura does NOT own: database schema -# -# TODO: (THIS IS NOT THE CASE FOR NOW!) -# Database schema is managed by golang-migrate (hasura/migrations/). -# Migrations run before Hasura starts — Hasura introspects the existing -# tables and applies metadata on top. This separation means schema -# changes go through SQL files with proper up/down rollbacks, not -# through Hasura's console. - FROM hasura/graphql-engine:v2.40.0 AS development -# No additional layers — the upstream image is used as-is +# No additional layers, the upstream image is used as-is # Environment variables and volume mounts handle everything at runtime EXPOSE 8080 -# What is metadata? -# Everything Hasura knows about your data that is NOT the SQL schema: -# - Which tables are tracked (visible via GraphQL) -# - Relationships between tables (how joins are exposed) -# - Permissions (what each role can query/mutate) -# - Actions (HTTP endpoints proxied to the Go API) -# - Event triggers (Go API called when DB rows change) -# - Remote schemas (if any external GraphQL APIs are stitched in) -# What metadata does NOT include: -# - SQL schema (tables, columns, indexes, FKs) — that is in migrations/ -# - Data (rows) — that is seeded separately FROM hasura/graphql-engine:v2.40.0 AS production COPY metadata/ /hasura-metadata/ -# Tell Hasura where to find the metadata on startup -# Must match the directory we copied to above -# Hasura applies this metadata automatically when the container starts +# tell Hasura where to find the metadata on startup +# must match the directory we copied to above ENV HASURA_GRAPHQL_METADATA_DIR=/hasura-metadata EXPOSE 8080 -# No CMD — Hasura's upstream image already defines the correct entrypoint. -# The graphql-engine binary starts automatically with the env vars -# injected by docker-compose.prod.yml. +# no CMD — Hasura's upstream image already defines the correct entrypoint +# the graphql-engine binary starts automatically with the env vars injected by docker-compose.prod.yml From b45d8a7335601365821c6286bc93a6b5686d554b Mon Sep 17 00:00:00 2001 From: lee Date: Thu, 16 Apr 2026 00:26:37 +0100 Subject: [PATCH 08/18] refactor(front|docker): caddy should only be used for production + fixed vite config to handle /api directly on the localhost fron route + fix docs route to display api documentation --- api/internal/docs/handler.go | 120 ++++++++++------------------------- docker-compose.prod.yml | 25 ++++---- front/Caddyfile | 64 ++++++++++++------- front/vite.config.ts | 28 +++++++- 4 files changed, 116 insertions(+), 121 deletions(-) diff --git a/api/internal/docs/handler.go b/api/internal/docs/handler.go index d132ac0..1705c55 100644 --- a/api/internal/docs/handler.go +++ b/api/internal/docs/handler.go @@ -2,6 +2,7 @@ package docs import ( "encoding/json" + "fmt" "net/http" ) @@ -20,9 +21,15 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { } 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) - w.Write([]byte(` + fmt.Fprintf(w, ` Operafix API @@ -32,14 +39,15 @@ func (h *Handler) scalar(w http.ResponseWriter, r *http.Request) { + >%s -`)) +`, 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) @@ -72,13 +80,11 @@ func openAPISpec() map[string]any { {"name": "auth", "description": "Authentication — login, logout, token refresh, magic-link"}, }, "paths": map[string]any{ - - // Health "/health": map[string]any{ "get": map[string]any{ "tags": []string{"health"}, "summary": "Health check", - "description": "Returns service status and build metadata. Used by Docker healthcheck and hosting platforms.", + "description": "Returns service status and build metadata.", "operationId": "getHealth", "responses": map[string]any{ "200": map[string]any{ @@ -86,19 +92,12 @@ func openAPISpec() map[string]any { "content": map[string]any{ "application/json": map[string]any{ "schema": ref("HealthResponse"), - "example": map[string]any{ - "status": "ok", - "version": "1.0.0", - "commit": "a1b2c3d", - }, }, }, }, }, }, }, - - // POST /auth/login "/auth/login": map[string]any{ "post": map[string]any{ "tags": []string{"auth"}, @@ -133,13 +132,11 @@ func openAPISpec() map[string]any { }, }, }, - - // POST /auth/refresh "/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 — rotation on every use.", + "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{ { @@ -160,18 +157,16 @@ func openAPISpec() map[string]any { }, }, }, - "401": errorResponse("Missing or expired refresh token — log in again"), + "401": errorResponse("Missing or expired refresh token"), "500": errorResponse("Refresh failed"), }, }, }, - - // POST /auth/logout "/auth/logout": map[string]any{ "post": map[string]any{ "tags": []string{"auth"}, "summary": "Logout", - "description": "Revokes all active refresh tokens for the user and clears the refresh token cookie. The access token remains valid until it expires (max 15 minutes).", + "description": "Revokes all active refresh tokens and clears the refresh token cookie.", "operationId": "logout", "security": []map[string]any{{"bearerAuth": []string{}}}, "responses": map[string]any{ @@ -191,13 +186,11 @@ func openAPISpec() map[string]any { }, }, }, - - // GET /auth/me "/auth/me": map[string]any{ "get": map[string]any{ "tags": []string{"auth"}, "summary": "Get current user", - "description": "Returns the authenticated user's profile. Requires a valid Bearer token in the Authorization header.", + "description": "Returns the authenticated user's profile.", "operationId": "getMe", "security": []map[string]any{{"bearerAuth": []string{}}}, "responses": map[string]any{ @@ -216,17 +209,14 @@ func openAPISpec() map[string]any { }, "401": errorResponse("Missing or invalid token"), "404": errorResponse("User not found"), - "500": errorResponse("Failed to fetch user"), }, }, }, - - // POST /auth/magic-link "/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 — even if the email is not registered — to prevent email enumeration.", + "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, @@ -248,7 +238,7 @@ func openAPISpec() map[string]any { }, "responses": map[string]any{ "200": map[string]any{ - "description": "Request received (email sent if address is registered)", + "description": "Request received", "content": map[string]any{ "application/json": map[string]any{ "schema": map[string]any{ @@ -292,13 +282,10 @@ func openAPISpec() map[string]any { }, "400": errorResponse("Missing token"), "401": errorResponse("Invalid or expired link"), - "500": errorResponse("Verification failed"), }, }, }, }, - - // Components "components": map[string]any{ "securitySchemes": map[string]any{ "bearerAuth": map[string]any{ @@ -322,16 +309,8 @@ func openAPISpec() 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", - }, + "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{ @@ -340,50 +319,24 @@ func openAPISpec() map[string]any { "properties": map[string]any{ "access_token": map[string]any{ "type": "string", - "description": "JWT access token — include in Authorization: Bearer header", + "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": "Access token lifetime in seconds", - "example": 900, - }, - "user": ref("User"), + "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", - }, + "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", - }, + "type": "string", + "enum": []string{"employee", "technician", "location_manager", "ops_manager", "admin", "super_admin"}, "example": "admin", }, }, @@ -392,10 +345,7 @@ func openAPISpec() map[string]any { "type": "object", "required": []string{"error"}, "properties": map[string]any{ - "error": map[string]any{ - "type": "string", - "example": "invalid credentials", - }, + "error": map[string]any{"type": "string", "example": "invalid credentials"}, }, }, }, @@ -412,10 +362,8 @@ func errorResponse(example string) map[string]any { "description": example, "content": map[string]any{ "application/json": map[string]any{ - "schema": ref("ErrorResponse"), - "example": map[string]any{ - "error": example, - }, + "schema": ref("ErrorResponse"), + "example": map[string]any{"error": example}, }, }, } @@ -424,7 +372,7 @@ func errorResponse(example string) map[string]any { func refreshCookieHeader() map[string]any { return map[string]any{ "Set-Cookie": map[string]any{ - "description": "HttpOnly refresh token cookie. Sent automatically by the browser on subsequent requests to /auth/refresh.", + "description": "HttpOnly refresh token cookie.", "schema": map[string]any{"type": "string"}, "example": "refresh_token=abc123; Path=/auth; HttpOnly; SameSite=Lax", }, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 23e0ce2..ae08371 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,8 +15,9 @@ services: restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-operafix} - POSTGRES_USER: ${POSTGRES_USER:-operafix} - # no fallback — must be set + POSTGRES_USER: + ${POSTGRES_USER:-operafix} + # no fallback — must be set POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data @@ -76,20 +77,18 @@ services: # no fallback — must be set HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} HASURA_GRAPHQL_JWT_SECRET: >- - {"type":"HS256","key":"${JWT_SECRET}"} # no fallback — must be set + {"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 - ACTION_BASE_URL: ${ACTION_BASE_URL:-http://api:8080} + # Caddy → api:8080 for actions, not exposed to internet + ACTION_BASE_URL: http://api:8080 # Raise connection pool for production load HASURA_GRAPHQL_PG_CONNECTIONS: "25" HASURA_GRAPHQL_TX_ISOLATION: serializable - # No volume mount — metadata ships inside the production image - ports: - # Only expose internally unless you're putting Hasura behind - # a reverse proxy - - "8080:8080" + # No ports — internal only. Caddy proxies /v1/* → hasura:8080. + # The Hasura console is disabled so there is nothing useful to expose. healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"] interval: 15s @@ -145,9 +144,8 @@ services: # R2_SECRET_KEY: ${R2_SECRET_KEY} # R2_BUCKET: ${R2_BUCKET:-operafix-media} # R2_PUBLIC_URL: ${R2_PUBLIC_URL} - # No volume mounts — production runs the compiled binary only - ports: - - "8081:8080" + + # No ports — internal only. Caddy proxies /api/* → api:8080. deploy: resources: limits: @@ -173,6 +171,8 @@ services: condition: service_healthy api: condition: service_started + environment: + DOMAIN: ${DOMAIN:-:80} ports: - "80:80" deploy: @@ -188,6 +188,7 @@ services: # go_mod_cache and front_node_modules are dev-only build artefacts volumes: postgres_data: + networks: operafix_net: driver: bridge diff --git a/front/Caddyfile b/front/Caddyfile index ad565e2..5259d93 100644 --- a/front/Caddyfile +++ b/front/Caddyfile @@ -1,36 +1,56 @@ -:80 { - root * /srv - - # Encode responses — Caddy handles gzip and zstd automatically +{$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} - # SPA fallback — any path that isn't a real file serves index.html - # so React Router can handle the route client-side - try_files {path} /index.html + transport http { + dial_timeout 5s + response_header_timeout 30s + } + } + } - file_server + 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} - # Cache headers - # Vite fingerprints assets (main.a1b2c3.js) — safe to cache forever - @fingerprinted { - path_regexp .*\.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|jpeg|svg|ico|webp)$ + transport http { + dial_timeout 5s + response_header_timeout 30s + } + } } - header @fingerprinted Cache-Control "public, max-age=31536000, immutable" - # index.html must never be cached - @html { - path *.html + 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 @html Cache-Control "no-store" - # Security headers header { - X-Frame-Options "DENY" + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" - Referrer-Policy "strict-origin" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" X-XSS-Protection "1; mode=block" - # Remove Caddy's Server header -Server } } - diff --git a/front/vite.config.ts b/front/vite.config.ts index 810aa15..f80f2a7 100644 --- a/front/vite.config.ts +++ b/front/vite.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ short_name: 'OperaFix', description: 'Operations without failures - High-End Restaurant Maintenance', - theme_color: '#0b0f19', // Updated to match our new dark mode background + theme_color: '#0b0f19', background_color: '#0b0f19', display: 'standalone', orientation: 'any', @@ -84,4 +84,30 @@ export default defineConfig({ '@': '/src', }, }, + + server: { + // Required for Docker — binds to all interfaces so port 5173 + // on the host reaches the Vite server inside the container. + host: '0.0.0.0', + port: 5173, + + proxy: { + // Go API — strips /api prefix before forwarding + // /api/auth/login → api:8080/auth/login + '/api': { + target: 'http://api:8080', + changeOrigin: true, + rewrite: path => path.replace(/^\/api/, ''), + }, + + // Hasura GraphQL — path kept as-is + // /v1/graphql → hasura:8080/v1/graphql + // ws: true handles WebSocket subscriptions (real-time dashboard) + '/v1': { + target: 'http://hasura:8080', + changeOrigin: true, + ws: true, + }, + }, + }, }) From 7f90346eb2b79af5a3f67ba1ef46beea7f32acf1 Mon Sep 17 00:00:00 2001 From: lee Date: Sun, 19 Apr 2026 16:18:38 +0100 Subject: [PATCH 09/18] chore(scripts): fix typo on filename --- scripts/{init-progres.sql => init-protgres.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scripts/{init-progres.sql => init-protgres.sql} (100%) diff --git a/scripts/init-progres.sql b/scripts/init-protgres.sql similarity index 100% rename from scripts/init-progres.sql rename to scripts/init-protgres.sql From 9e5c52a3f62be36b70760ae7f445e47e23bcf640 Mon Sep 17 00:00:00 2001 From: lee Date: Fri, 24 Apr 2026 23:24:11 +0100 Subject: [PATCH 10/18] refactor(services): update services versions --- .gitignore | 1 + api/Dockerfile | 28 +++-------- api/go.mod | 16 +++--- api/go.sum | 32 ++++++------ docker-compose.dev.yml | 108 ++++++++++------------------------------ docker-compose.prod.yml | 57 +++++++-------------- front/Dockerfile | 14 ++---- hasura/Dockerfile | 4 +- 8 files changed, 81 insertions(+), 179 deletions(-) diff --git a/.gitignore b/.gitignore index 67b73d5..3f52a08 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ verify_ui.py # Air golang tmp/ +vendor/ diff --git a/api/Dockerfile b/api/Dockerfile index 18e2a44..06d7adc 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,5 +1,4 @@ -# Shared foundation used by both development and builder stages. -FROM golang:1.23-alpine AS base +FROM golang:1.26.2-alpine AS base # Install system dependencies: # git — required by `go mod download` for private modules @@ -17,20 +16,14 @@ COPY go.mod go.sum ./ RUN go mod download && go mod verify -# Used by docker-compose.dev.yml. -# Does NOT copy source code — source is mounted as a live volume at -# runtime so that file changes are visible inside the container immediately +# Used in development mode # `air` watches the mounted source and rebuilds the binary on every save FROM base AS development -# Install `air` — a file watcher that rebuilds and restarts the Go -# Config lives in api/.air.toml -RUN go install github.com/air-verse/air@v1.52.3 +RUN go install github.com/air-verse/air@v1.65.1 EXPOSE 8080 -# air reads its config from .air.toml in the working directory (/app), -# which is satisfied by the volume mount of ./api at runtime. CMD ["air", "-c", ".air.toml"] # compiles the production binary @@ -53,19 +46,14 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -o /bin/operafix-api \ ./cmd/server -# The final deployable image. Built on `scratch` — an empty base image -# with literally nothing in it: no shell, no package manager, no OS utils +# 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 +# work with: no bash, no curl, no wget, no package manager FROM scratch AS production -# copy TLS certificates from the builder stage. -# without this, any HTTPS call +# copy TLS certificates from the builder stage COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - -# copy timezone database from the builder stage -# Without this, time.LoadLocation("Europe/Lisbon") panics at runtime. -# All timestamp conversions for display (UTC → location timezone) depend on this. COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /bin/operafix-api /operafix-api @@ -73,7 +61,7 @@ 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. +# escalating privileges even if a vulnerability is exploited USER 65532:65532 ENTRYPOINT ["/operafix-api"] diff --git a/api/go.mod b/api/go.mod index 6220625..e269475 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,18 +1,18 @@ module api -go 1.23.12 +go 1.26.2 require ( - github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.6.0 - golang.org/x/crypto v0.24.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-20221227161230-091c0ba34f0a // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/text v0.16.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 index ec85949..b562ce4 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,31 +1,31 @@ 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.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= -github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +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= diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cedb7e5..6280723 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,7 +10,7 @@ # 5173 → front (Vite dev server with HMR) services: postgres: - image: postgres:16-alpine + image: postgres:17-alpine container_name: operafix_postgres restart: unless-stopped environment: @@ -20,19 +20,14 @@ services: volumes: - postgres_data:/var/lib/postgresql/data # init-postgres.sql runs ONCE on first boot (when the volume is empty) - # It enables PostgreSQL extensions that require superuser privileges: + # 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 - # Mounted read-only (:ro) — this file should never be written at runtime - ./scripts/init-postgres.sql:/docker-entrypoint-initdb.d/00-init.sql:ro ports: - "5432:5432" healthcheck: - # pg_isready polls until Postgres is actually accepting connections - # Other services use `condition: service_healthy` to wait for this - # Without healthchecks, migrate might run before Postgres is ready - # and fail with "connection refused" test: [ "CMD-SHELL", @@ -44,32 +39,20 @@ services: networks: - operafix_net - # A one-shot service: runs all SQL migration files in order, then exits - # It is NOT a long-running server - # - # Migration files live in hasura/migrations/ and are named: - # 001_companies.up.sql, 001_companies.down.sql - # 002_location_types.up.sql, ... - # - # golang-migrate reads the numeric prefix to determine order. - # It tracks which migrations have already run in a schema_migrations - # table inside Postgres, so re-running is safe (idempotent) + # a one-shot service: runs all SQL migration files in order then exits migrate: - image: migrate/migrate:v4.17.0 + image: migrate/migrate:v4.19.0 container_name: operafix_migrate restart: "no" depends_on: postgres: - # Wait until postgres passes its healthcheck before starting - # Without this, migrate would try to connect before Postgres is ready + # wait until postgres passes its healthcheck before starting condition: service_healthy volumes: - ./hasura/migrations:/migrations:ro command: - # -path: where to find the migration files inside the container + # where to find the migration files inside the container - "-path=/migrations" - # -database: full Postgres connection string - # Uses the same credentials as the postgres service above - "-database=postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD:-operafix_dev_password}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=disable" # up: apply all pending migrations not yet recorded in schema_migrations - "up" @@ -80,7 +63,6 @@ services: build: context: ./hasura dockerfile: Dockerfile - # Use the development stage (plain upstream image) target: development container_name: operafix_hasura restart: unless-stopped @@ -88,29 +70,26 @@ services: postgres: condition: service_healthy migrate: - # Wait until all SQL migrations have run successfully - # Hasura introspects the DB schema on boot — tables must exist first + # wait until all SQL migrations have run successfully condition: service_completed_successfully environment: - # Connection string Hasura uses to talk to Postgres # >- is YAML block scalar: joins lines into one string without newline - # Required here to avoid line breaks inside the URL + # 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:-changeme_admin_secret_32chars} + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-pNZxIfzAD43hka0HrR02JAmsZmLU10bI} HASURA_GRAPHQL_JWT_SECRET: >- - {"type":"HS256","key":"${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long}"} + {"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 - # When a DB event fires or an action is invoked, Hasura POSTs to: + # 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 - # Keep low locally. Raise for production based on observed load + # max simultaneous postgres connections hasura will hold open HASURA_GRAPHQL_PG_CONNECTIONS: "10" HASURA_GRAPHQL_TX_ISOLATION: serializable volumes: @@ -118,34 +97,21 @@ services: ports: - "8080:8080" healthcheck: - # /healthz returns 200 only when Hasura is fully booted - # and has successfully connected to Postgres. 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 + # 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 - # Handles everything Hasura cannot do directly: - # /auth — JWT issuance, refresh tokens, magic-link email login - # /upload — presigned Cloudflare R2 URLs for photo uploads - # /qr — QR code + printable label PDF generation - # /cron — SLA monitor, PM scheduler, nightly analytics aggregation - # /hasura — Action + Event Trigger webhook handlers - # /onboard — [LATER] industry template seeding on company signup - # /notify — [LATER] email (Postmark) + SMS (Twilio) dispatch - # - # In development, `air` watches for .go file changes and rebuilds - # the binary automatically — no need to restart the container api: build: context: ./api dockerfile: Dockerfile - # Runs `air` for hot-reload + # runs `air` for hot-reload target: development container_name: operafix_api restart: unless-stopped @@ -153,29 +119,21 @@ services: postgres: condition: service_healthy hasura: - # Go API must start after Hasura is ready. - # It makes privileged GraphQL calls to Hasura during onboarding - # and would fail immediately if Hasura isn't accepting requests. + # go API must start after Hasura is ready condition: service_healthy environment: - # Direct Postgres connection string for the Go service. - # Used for operations that bypass Hasura: auth queries, - # cron jobs writing analytics data, migrations during tests. 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" resolves to the hasura container on the Docker network. + # hasura internal endpoint for privileged GraphQL queries HASURA_ENDPOINT: http://hasura:8080/v1/graphql HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme_admin_secret_32chars} - # JWT signing key — must be identical to Hasura's JWT secret above. - # Go signs tokens with this key; Hasura verifies them with the same key. - JWT_SECRET: ${JWT_SECRET:-changeme_jwt_secret_min_32_characters_long} + # 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 - # debug | info | warn | error LOG_LEVEL: debug PORT: 8080 # # External services — leave blank locally unless actively testing @@ -191,24 +149,16 @@ services: # 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. + # mount api/ source into the container so `air` detects file changes + # :delegated, on macOS, relaxes mount consistency for better performance - ./api:/app:delegated - # Named volume for the Go module cache ($GOPATH/pkg/mod). - # Avoids re-downloading all dependencies on every `docker compose build`. + # avoids re-downloading all dependencies on every `docker compose build` - go_mod_cache:/root/go/pkg/mod ports: - # (8080 is already used by Hasura, so API uses 8081 on the host) - "8081:8080" networks: - operafix_net - # Serves the React app with Vite's dev server in development. - # HMR (Hot Module Replacement) means the browser updates instantly - # on file save — no full page reload. - # - # In production: `docker build --target production ./front` compiles - # static assets and serves them via Caddy front: build: context: ./front @@ -218,21 +168,15 @@ services: restart: unless-stopped depends_on: hasura: - # Frontend needs Hasura ready before it can serve meaningful content - # Without this the app boots but every GraphQL query fails on load condition: service_healthy environment: - # VITE_* prefix is mandatory — Vite only injects variables with this - # prefix into the browser bundle. Unprefixed vars stay server-side - # HTTP endpoint for GraphQL queries and mutations + # VITE_* prefix is mandatory, vite only injects variables with this VITE_GRAPHQL_URL: ${VITE_GRAPHQL_URL:-http://localhost:8080/v1/graphql} - # WebSocket endpoint for real-time subscriptions - # (e.g. manager dashboard updating live when issue status changes) VITE_GRAPHQL_WS: ${VITE_GRAPHQL_WS:-ws://localhost:8080/v1/graphql} - # Go API base URL for auth, file uploads, QR generation + # 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 + # mount front/ source so Vite's file watcher picks up changes - ./front:/app:delegated - front_node_modules:/app/node_modules ports: @@ -241,9 +185,7 @@ services: - operafix_net volumes: - # Our entire database postgres_data: - # Go module cache. Safe to delete — re-downloaded on next build go_mod_cache: front_node_modules: networks: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ae08371..e294bd6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,27 +1,17 @@ -# Key differences from docker-compose.dev.yml: -# - All services build their production stage (compiled binaries, -# static assets — no hot-reload tooling) -# - Hasura console and dev mode are disabled -# - Postgres is not exposed on the host — internal only -# - No source code volume mounts -# - No go_mod_cache or front_node_modules volumes -# - Log level is info, not debug -# - Resource limits set on every service -# - Caddy serves the frontend on port 80 services: postgres: - image: postgres:16-alpine + image: postgres:17-alpine container_name: operafix_postgres restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:-operafix} - POSTGRES_USER: - ${POSTGRES_USER:-operafix} - # no fallback — must be set + POSTGRES_USER: ${POSTGRES_USER:-operafix} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - postgres_data:/var/lib/postgresql/data - ./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: [ @@ -40,10 +30,9 @@ services: networks: - operafix_net - # Runs pending migrations on every deploy, then exits - # Safe to run repeatedly — already-applied migrations are skipped + # a one-shot service: runs all SQL migration files in order then exits migrate: - image: migrate/migrate:v4.17.0 + image: migrate/migrate:v4.19.0 container_name: operafix_migrate restart: "no" depends_on: @@ -53,7 +42,7 @@ services: - ./hasura/migrations:/migrations:ro command: - "-path=/migrations" - - "-database=postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=require" + - "-database=postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=disable" - "up" networks: - operafix_net @@ -62,7 +51,6 @@ services: build: context: ./hasura dockerfile: Dockerfile - # metadata copied into the image at build time target: production container_name: operafix_hasura restart: unless-stopped @@ -74,7 +62,6 @@ services: environment: HASURA_GRAPHQL_DATABASE_URL: >- postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix} - # no fallback — must be set HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} HASURA_GRAPHQL_JWT_SECRET: >- {"type":"HS256","key":"${JWT_SECRET}"} @@ -82,13 +69,10 @@ services: HASURA_GRAPHQL_DEV_MODE: "false" HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup,http-log,webhook-log HASURA_GRAPHQL_METADATA_DIR: /hasura-metadata - # Caddy → api:8080 for actions, not exposed to internet ACTION_BASE_URL: http://api:8080 - # Raise connection pool for production load HASURA_GRAPHQL_PG_CONNECTIONS: "25" HASURA_GRAPHQL_TX_ISOLATION: serializable - # No ports — internal only. Caddy proxies /v1/* → hasura:8080. - # The Hasura console is disabled so there is nothing useful to expose. + # no ports, internal only. Caddy proxies /v1/* → hasura:8080 healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8080/healthz || exit 1"] interval: 15s @@ -110,7 +94,6 @@ services: dockerfile: Dockerfile target: production args: - # Injected into the binary via ldflags — set by your CI/CD pipeline VERSION: ${VERSION:-unknown} COMMIT: ${COMMIT:-unknown} BUILD_TIME: ${BUILD_TIME:-unknown} @@ -123,29 +106,27 @@ services: condition: service_healthy environment: DATABASE_URL: >- - postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=require + postgres://${POSTGRES_USER:-operafix}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-operafix}?sslmode=disable HASURA_ENDPOINT: http://hasura:8080/v1/graphql - # no fallback HASURA_ADMIN_SECRET: ${HASURA_ADMIN_SECRET} - # no fallback 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} + # POSTMARK_API_KEY: ${POSTMARK_API_KEY} + # POSTMARK_FROM: ${POSTMARK_FROM} # TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID} - # TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN} + # 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} + # 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. + # no ports, internal only. Caddy proxies /api/* → api:8080 deploy: resources: limits: @@ -184,8 +165,6 @@ services: networks: - operafix_net -# Only postgres_data in production -# go_mod_cache and front_node_modules are dev-only build artefacts volumes: postgres_data: diff --git a/front/Dockerfile b/front/Dockerfile index 176edca..ef5ea40 100644 --- a/front/Dockerfile +++ b/front/Dockerfile @@ -4,8 +4,7 @@ # builder → compiles optimised static assets via `npm run build` # production → Caddy serving the static build from /srv -# shared foundation -FROM node:20-alpine AS base +FROM node:24-alpine AS base WORKDIR /app COPY package.json package-lock.json ./ @@ -14,7 +13,7 @@ COPY package.json package-lock.json ./ 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: +# Does NOT copy source: source is mounted as a live volume at runtime: # volumes: # - ./front:/app:delegated # - front_node_modules:/app/node_modules @@ -24,7 +23,6 @@ EXPOSE 5173 CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] -# Compiles the React app into optimised static assets FROM base AS builder COPY . . @@ -39,15 +37,9 @@ ENV VITE_API_URL=$VITE_API_URL # Run the Vite production build. RUN npm run build -# Why Caddy over nginx: -# - Single-line compression (zstd + gzip) vs nginx's gzip_* block -# - Correct mime types out of the box (no mime.types file needed) -# - Simpler SPA routing config -# - Strips Server header by default -FROM caddy:2.8-alpine AS production +FROM caddy:2.11-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 diff --git a/hasura/Dockerfile b/hasura/Dockerfile index f33cf30..8bbedb3 100644 --- a/hasura/Dockerfile +++ b/hasura/Dockerfile @@ -1,10 +1,10 @@ -FROM hasura/graphql-engine:v2.40.0 AS development +FROM hasura/graphql-engine:v2.48.16 AS development # No additional layers, the upstream image is used as-is # Environment variables and volume mounts handle everything at runtime EXPOSE 8080 -FROM hasura/graphql-engine:v2.40.0 AS production +FROM hasura/graphql-engine:v2.48.16 AS production COPY metadata/ /hasura-metadata/ From fe75238c4688464fc1271db140c04c1c921b4b40 Mon Sep 17 00:00:00 2001 From: lee Date: Tue, 28 Apr 2026 21:26:52 +0100 Subject: [PATCH 11/18] chore(db): bump postgres version + fix typo for postgres initialization script --- docker-compose.dev.yml | 4 ++-- docker-compose.prod.yml | 4 ++-- scripts/{init-protgres.sql => init-postgres.sql} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename scripts/{init-protgres.sql => init-postgres.sql} (100%) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6280723..6e1e826 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,7 +10,7 @@ # 5173 → front (Vite dev server with HMR) services: postgres: - image: postgres:17-alpine + image: postgres:18-alpine container_name: operafix_postgres restart: unless-stopped environment: @@ -18,7 +18,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-operafix} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-operafix_dev_password} volumes: - - postgres_data:/var/lib/postgresql/data + - 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e294bd6..3fb6fdf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:17-alpine + image: postgres:18-alpine container_name: operafix_postgres restart: unless-stopped environment: @@ -8,7 +8,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-operafix} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - - postgres_data:/var/lib/postgresql/data + - 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 diff --git a/scripts/init-protgres.sql b/scripts/init-postgres.sql similarity index 100% rename from scripts/init-protgres.sql rename to scripts/init-postgres.sql From 57796cdf4ea3f6c2f189770a7383ea9d0908e6a7 Mon Sep 17 00:00:00 2001 From: lee Date: Tue, 28 Apr 2026 22:59:54 +0100 Subject: [PATCH 12/18] chore(fmt&lint): use deno instead of biome --- biome.json | 66 -- front/deno.json | 34 + front/index.html | 2 +- front/src/App.tsx | 93 ++- front/src/components/Badge.tsx | 4 +- front/src/components/Button.tsx | 9 +- front/src/components/Card.tsx | 10 +- front/src/components/Input.tsx | 29 +- front/src/components/Layout.tsx | 237 +++--- front/src/components/PWAInstallPrompt.tsx | 33 +- front/src/components/StatsCard.tsx | 24 +- front/src/components/Table.tsx | 10 +- front/src/context/AuthContext.tsx | 8 +- front/src/context/ThemeContext.tsx | 7 +- front/src/context/ToastContext.tsx | 20 +- front/src/data/mockData.ts | 27 +- front/src/index.css | 4 +- front/src/lib/utils.ts | 2 +- front/src/main.tsx | 2 +- front/src/pages/Analytics.tsx | 143 ++-- front/src/pages/Dashboard.tsx | 213 +++-- front/src/pages/Docs/ComponentsDocs.tsx | 732 +++++++++--------- front/src/pages/Docs/NextPhaseDocs.tsx | 170 ++-- front/src/pages/Docs/SchemaDocs.tsx | 271 +++---- front/src/pages/Docs/ThemingDocs.tsx | 298 ++++--- front/src/pages/Docs/UserJourneyDocs.tsx | 274 ++++--- front/src/pages/Docs/WorkflowDocs.tsx | 283 ++++--- front/src/pages/Docs/index.tsx | 54 +- front/src/pages/Equipment/EquipmentInfo.tsx | 215 ++--- front/src/pages/Equipment/EquipmentList.tsx | 217 +++--- front/src/pages/Locations/LocationsList.tsx | 148 ++-- front/src/pages/Login.tsx | 130 ++-- front/src/pages/Preventive/PreventiveList.tsx | 213 +++-- front/src/pages/QRScanner.tsx | 331 ++++---- front/src/pages/Reports/ReportCreation.tsx | 301 +++---- front/src/pages/Reports/ReportDetail.tsx | 172 ++-- front/src/pages/Reports/ReportsList.tsx | 271 +++---- front/src/pages/Settings.tsx | 257 +++--- .../Technicians/TechnicianAssignment.tsx | 240 +++--- front/tsconfig.json | 1 + front/vite.config.ts | 5 +- 41 files changed, 2680 insertions(+), 2880 deletions(-) delete mode 100644 biome.json create mode 100644 front/deno.json 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/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/front/index.html b/front/index.html index 5cb5685..e3b0085 100644 --- a/front/index.html +++ b/front/index.html @@ -1,4 +1,4 @@ - + diff --git a/front/src/App.tsx b/front/src/App.tsx index f32f085..48982d4 100644 --- a/front/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/front/src/components/Badge.tsx b/front/src/components/Badge.tsx index 7d9201a..7b00590 100644 --- a/front/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/front/src/components/Button.tsx b/front/src/components/Button.tsx index cbe93a8..7f21752 100644 --- a/front/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/front/src/components/Card.tsx b/front/src/components/Card.tsx index f9991e9..bebb412 100644 --- a/front/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/front/src/components/Input.tsx b/front/src/components/Input.tsx index f6b2464..3e5491c 100644 --- a/front/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/front/src/components/Layout.tsx b/front/src/components/Layout.tsx index a3b1269..3aa4d2c 100644 --- a/front/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 */} -