From c9580ac2eb818420128b1ba21fef749e79f5edec Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 27 Apr 2026 21:53:36 +1200 Subject: [PATCH] v1.4 - Login fixes, etc --- README.md | 18 +- backend/app/core/config.py | 23 + backend/app/db/migrations.py | 127 ++- backend/app/main.py | 42 +- backend/app/schemas/product.py | 3 +- backend/app/services/costing_engine.py | 2 + backend/tests/test_costing_engine.py | 77 +- frontend/package-lock.json | 353 ++++++++- frontend/package.json | 6 +- frontend/src/lib/api.test.ts | 60 ++ frontend/src/lib/api.ts | 53 +- frontend/src/lib/components/AdminShell.svelte | 55 +- .../src/lib/components/ClientShell.svelte | 736 ++++++++++++++++-- .../src/lib/components/MixWorkspace.svelte | 117 ++- frontend/src/lib/mock.ts | 2 + frontend/src/lib/session.ts | 16 +- frontend/src/lib/types.ts | 2 + frontend/src/routes/+page.svelte | 171 +++- frontend/src/routes/+page.ts | 12 +- frontend/src/routes/admin/+page.svelte | 17 +- frontend/src/routes/admin/+page.ts | 4 +- .../src/routes/admin/client-access/+page.ts | 4 +- frontend/src/routes/load-fetch.test.ts | 111 +++ frontend/src/routes/mixes/+page.svelte | 209 ++++- frontend/src/routes/mixes/+page.ts | 4 +- frontend/src/routes/mixes/[id]/+page.ts | 4 +- frontend/src/routes/mixes/new/+page.ts | 4 +- frontend/src/routes/products/+page.svelte | 84 +- frontend/src/routes/products/+page.ts | 4 +- frontend/src/routes/raw-materials/+page.ts | 10 +- frontend/src/routes/scenarios/+page.ts | 4 +- frontend/src/routes/settings/+page.svelte | 135 ++++ frontend/vite.config.ts | 16 +- 33 files changed, 2283 insertions(+), 202 deletions(-) create mode 100644 frontend/src/lib/api.test.ts create mode 100644 frontend/src/routes/load-fetch.test.ts create mode 100644 frontend/src/routes/settings/+page.svelte diff --git a/README.md b/README.md index 6bc7989..75af02f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ Create a virtual environment, install dependencies, then run: ```bash cd backend pip install -e . -uvicorn app.main:app --reload +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload ``` -API docs will be available at `http://localhost:8000/docs`. +API docs will be available at `http://localhost:8000/docs` on the server itself, or `http://:8000/docs` from another machine on the same network. Useful commands: @@ -41,7 +41,19 @@ npm install npm run dev ``` -Set `PUBLIC_API_BASE_URL` if the backend is not running on `http://localhost:8000`. +The frontend dev server now binds to `0.0.0.0`, so you can open it from another machine at `http://:5173`. + +By default the browser will call the backend on the same hostname and port `8000`. For example, if you open the UI at `http://10.0.0.124:5173`, it will call `http://10.0.0.124:8000`. + +Useful environment variables: + +```bash +PUBLIC_API_PORT=8000 +PUBLIC_API_BASE_URL=http://10.0.0.124:8000 +CORS_ALLOW_ORIGINS=http://10.0.0.124:5173 +``` + +Set `PUBLIC_API_BASE_URL` when the API is on a different machine or behind a different public URL. Set `CORS_ALLOW_ORIGINS` or `CORS_ALLOW_ORIGIN_REGEX` if you want to narrow backend CORS more tightly than the default private-network allowance. ## Delivered in this MVP diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0a1dc4a..624bd3b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -2,6 +2,20 @@ import os from dataclasses import dataclass +DEFAULT_CORS_ALLOW_ORIGIN_REGEX = ( + r"^https?://(" + r"localhost|127\.0\.0\.1|" + r"10\.\d{1,3}\.\d{1,3}\.\d{1,3}|" + r"192\.168\.\d{1,3}\.\d{1,3}|" + r"172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}" + r")(:\d+)?$" +) + + +def _parse_csv_env(value: str) -> tuple[str, ...]: + return tuple(part.strip() for part in value.split(",") if part.strip()) + + @dataclass(frozen=True) class Settings: app_name: str @@ -14,6 +28,8 @@ class Settings: admin_email: str admin_password: str auth_secret: str + cors_allow_origins: tuple[str, ...] + cors_allow_origin_regex: str @classmethod def from_env(cls) -> "Settings": @@ -28,6 +44,13 @@ class Settings: admin_email=os.getenv("ADMIN_EMAIL", "admin@lean101.local"), admin_password=os.getenv("ADMIN_PASSWORD", "lean101-admin"), auth_secret=os.getenv("AUTH_SECRET", "lean-101-local-dev-secret"), + cors_allow_origins=_parse_csv_env( + os.getenv( + "CORS_ALLOW_ORIGINS", + "http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174", + ) + ), + cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX), ) diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 2948d0d..4c9d17c 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -1,6 +1,8 @@ from __future__ import annotations -from sqlalchemy import inspect, text +from dataclasses import dataclass, field + +from sqlalchemy import MetaData, inspect, text from sqlalchemy.engine import Engine @@ -20,6 +22,27 @@ TENANT_TABLES = { } +@dataclass(frozen=True) +class MigrationReport: + created_tables: tuple[str, ...] = () + added_columns: tuple[str, ...] = () + synced_tenant_rows: dict[str, int] = field(default_factory=dict) + + def has_changes(self) -> bool: + return bool(self.created_tables or self.added_columns or self.synced_tenant_rows) + + def summary(self) -> str: + parts: list[str] = [] + if self.created_tables: + parts.append(f"created tables: {', '.join(self.created_tables)}") + if self.added_columns: + parts.append(f"patched columns: {', '.join(self.added_columns)}") + if self.synced_tenant_rows: + counts = ", ".join(f"{table}={count}" for table, count in sorted(self.synced_tenant_rows.items())) + parts.append(f"synced tenant rows: {counts}") + return "; ".join(parts) if parts else "schema already up to date" + + def _has_column(engine: Engine, table_name: str, column_name: str) -> bool: inspector = inspect(engine) try: @@ -40,25 +63,41 @@ def _table_exists(engine: Engine, table_name: str) -> bool: return inspect(engine).has_table(table_name) -def ensure_tenant_columns(engine: Engine) -> None: +def ensure_metadata_tables(engine: Engine, metadata: MetaData) -> tuple[str, ...]: + missing_tables = tuple(table.name for table in metadata.sorted_tables if not _table_exists(engine, table.name)) + if missing_tables: + metadata.create_all(bind=engine) + return missing_tables + + +def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]: + added_columns: list[str] = [] for table_name in TENANT_TABLES: if _table_exists(engine, table_name): - _add_tenant_column(engine, table_name) + if not _has_column(engine, table_name, "tenant_id"): + _add_tenant_column(engine, table_name) + added_columns.append(f"{table_name}.tenant_id") + return tuple(added_columns) -def sync_tenant_ids(engine: Engine) -> None: - if not _table_exists(engine, "client_accounts"): - return +def sync_tenant_ids(engine: Engine) -> dict[str, int]: + existing_tables = set(inspect(engine).get_table_names()) + if "client_accounts" not in existing_tables: + return {} + + synced_rows: dict[str, int] = {} with engine.begin() as connection: default_tenant = connection.execute( text("SELECT tenant_id FROM client_accounts ORDER BY id LIMIT 1") ).scalar_one_or_none() if not default_tenant: - return + return {} statements = [ - text( + ( + "client_users", + text( """ UPDATE client_users SET tenant_id = ( @@ -68,8 +107,11 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "client_feature_access", + text( """ UPDATE client_feature_access SET tenant_id = ( @@ -79,15 +121,21 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "raw_materials", + text( """ UPDATE raw_materials SET tenant_id = :default_tenant WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "raw_material_price_versions", + text( """ UPDATE raw_material_price_versions SET tenant_id = ( @@ -97,8 +145,11 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "mixes", + text( """ UPDATE mixes SET tenant_id = COALESCE( @@ -111,8 +162,11 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "mix_ingredients", + text( """ UPDATE mix_ingredients SET tenant_id = ( @@ -122,8 +176,11 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "products", + text( """ UPDATE products SET tenant_id = COALESCE( @@ -141,15 +198,21 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "scenarios", + text( """ UPDATE scenarios SET tenant_id = :default_tenant WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "costing_results", + text( """ UPDATE costing_results SET tenant_id = COALESCE( @@ -167,29 +230,51 @@ def sync_tenant_ids(engine: Engine) -> None: ) WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "process_cost_rules", + text( """ UPDATE process_cost_rules SET tenant_id = :default_tenant WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "packaging_cost_rules", + text( """ UPDATE packaging_cost_rules SET tenant_id = :default_tenant WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), - text( + ( + "freight_cost_rules", + text( """ UPDATE freight_cost_rules SET tenant_id = :default_tenant WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' """ + ), ), ] - for statement in statements: - connection.execute(statement, {"default_tenant": default_tenant}) + for table_name, statement in statements: + if table_name not in existing_tables: + continue + result = connection.execute(statement, {"default_tenant": default_tenant}) + if result.rowcount and result.rowcount > 0: + synced_rows[table_name] = result.rowcount + + return synced_rows + + +def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport: + created_tables = ensure_metadata_tables(engine, metadata) + added_columns = ensure_tenant_columns(engine) + return MigrationReport(created_tables=created_tables, added_columns=added_columns) diff --git a/backend/app/main.py b/backend/app/main.py index 43801ed..339166d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,9 @@ +import logging import os import sys from contextlib import asynccontextmanager from pathlib import Path +from threading import Lock if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -19,16 +21,41 @@ from app.api.raw_materials import router as raw_materials_router from app.api.scenarios import router as scenarios_router from app.core.config import settings from app.db.session import Base, engine -from app.db.migrations import ensure_tenant_columns, sync_tenant_ids +from app.db.migrations import MigrationReport, bootstrap_schema, sync_tenant_ids from app.seed import seed_if_empty +logger = logging.getLogger("data_entry_app.startup") +_database_ready = False +_database_ready_lock = Lock() + + +def ensure_database_ready() -> MigrationReport: + global _database_ready + + if _database_ready: + return MigrationReport() + + with _database_ready_lock: + if _database_ready: + return MigrationReport() + + schema_report = bootstrap_schema(engine, Base.metadata) + seed_if_empty() + tenant_sync_report = sync_tenant_ids(engine) + + report = MigrationReport( + created_tables=schema_report.created_tables, + added_columns=schema_report.added_columns, + synced_tenant_rows=tenant_sync_report, + ) + logger.info("Database startup checks complete: %s", report.summary()) + _database_ready = True + return report + @asynccontextmanager async def lifespan(_: FastAPI): - Base.metadata.create_all(bind=engine) - ensure_tenant_columns(engine) - seed_if_empty() - sync_tenant_ids(engine) + ensure_database_ready() yield @@ -36,7 +63,8 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://localhost:5174"], + allow_origins=list(settings.cors_allow_origins), + allow_origin_regex=settings.cors_allow_origin_regex, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -81,6 +109,8 @@ def healthcheck(): if __name__ == "__main__": + report = ensure_database_ready() + print(f"Database startup checks complete: {report.summary()}") uvicorn.run( app, host=os.getenv("HOST", "0.0.0.0"), diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 645a461..b273b16 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -56,6 +56,8 @@ class ProductRead(BaseModel): class ProductCostBreakdown(BaseModel): product_id: int product_name: str + client_name: str + mix_name: str cleaned_product_cost: float grading_cost: float bagging_cost: float @@ -67,4 +69,3 @@ class ProductCostBreakdown(BaseModel): wholesale_price: float | None warnings: list[str] inputs: dict[str, object] - diff --git a/backend/app/services/costing_engine.py b/backend/app/services/costing_engine.py index 56e0374..3328483 100644 --- a/backend/app/services/costing_engine.py +++ b/backend/app/services/costing_engine.py @@ -224,6 +224,8 @@ def calculate_product_cost(db: Session, product_id: int, overrides: dict | None return { "product_id": product.id, "product_name": product.name, + "client_name": product.client_name, + "mix_name": product.mix.name if product.mix else "", "cleaned_product_cost": round(cleaned_product_cost, 4), "grading_cost": round(grading_cost, 4), "bagging_cost": round(bagging_cost, 4), diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index 6a601ef..3ddc22e 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -1,10 +1,12 @@ from datetime import date from fastapi.testclient import TestClient -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool from app.core.config import settings +from app.db.migrations import bootstrap_schema, sync_tenant_ids from app.db.session import Base from app.main import app from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule @@ -77,6 +79,8 @@ def test_mix_and_product_cost_breakdown(): assert mix_result["total_mix_kg"] == 280 assert mix_result["mix_cost_per_kg"] == 0.5114 + assert product_result["client_name"] == "Specialty Feeds" + assert product_result["mix_name"] == "Pigeon Mix" assert product_result["finished_product_delivered"] == 14.208 assert product_result["distributor_price"] == 18.3329 assert product_result["wholesale_price"] == 17.3268 @@ -181,3 +185,74 @@ def test_client_access_endpoints(): export_response = client.get("/api/powerbi/client-access", headers=headers) assert export_response.status_code == 200 assert "client_rows" in export_response.json() + + +def test_bootstrap_schema_creates_missing_tables_and_patches_legacy_tenant_columns(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + with engine.begin() as connection: + connection.execute( + text( + """ + CREATE TABLE client_accounts ( + id INTEGER PRIMARY KEY, + tenant_id VARCHAR(64), + name VARCHAR(255), + client_code VARCHAR(64), + status VARCHAR(32), + powerbi_workspace VARCHAR(128), + notes TEXT, + created_at DATETIME + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE TABLE raw_materials ( + id INTEGER PRIMARY KEY, + name VARCHAR(255), + supplier VARCHAR(255), + unit_of_measure VARCHAR(64), + kg_per_unit FLOAT, + status VARCHAR(32), + notes TEXT, + created_at DATETIME + ) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO client_accounts (id, tenant_id, name, client_code, status) + VALUES (1, 'specialty-feeds', 'Specialty Feeds', 'SPEC', 'active') + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO raw_materials (id, name, supplier, unit_of_measure, kg_per_unit, status) + VALUES (1, 'Maize', 'Example Supplier', 'tonne', 1000, 'active') + """ + ) + ) + + report = bootstrap_schema(engine, Base.metadata) + synced_rows = sync_tenant_ids(engine) + + assert "products" in report.created_tables + assert "raw_materials.tenant_id" in report.added_columns + assert "tenant_id" in {column["name"] for column in inspect(engine).get_columns("raw_materials")} + assert synced_rows["raw_materials"] == 1 + + with engine.begin() as connection: + tenant_id = connection.execute(text("SELECT tenant_id FROM raw_materials WHERE id = 1")).scalar_one() + + assert tenant_id == "specialty-feeds" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e79043..051339d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,8 @@ "@sveltejs/kit": "^2.7.1", "svelte": "^5.0.0", "typescript": "^5.5.4", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.0.0" } }, "node_modules/@emnapi/core": { @@ -503,6 +504,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -510,6 +522,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -524,6 +543,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -547,6 +679,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -557,6 +699,16 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -567,6 +719,13 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -605,6 +764,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -630,6 +796,26 @@ } } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1010,8 +1196,14 @@ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -1103,6 +1295,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -1128,6 +1327,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.55.5", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", @@ -1156,6 +1369,23 @@ "node": ">=18" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1173,6 +1403,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -1304,6 +1544,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index da446f7..9a0ce4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,13 +6,15 @@ "scripts": { "dev": "vite dev", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.2.0", "@sveltejs/kit": "^2.7.1", "svelte": "^5.0.0", "typescript": "^5.5.4", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.0.0" } } diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..9838f3a --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { api } from './api'; + +type JsonRecord = Record; + +function jsonResponse(body: JsonRecord[] | JsonRecord) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); +} + +describe('api fetch injection', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it.each([ + { + label: 'raw materials', + call: (fetcher: typeof fetch) => api.rawMaterials(fetcher), + path: '/api/raw-materials', + body: [{ id: 1, name: 'Lime' }] + }, + { + label: 'mixes', + call: (fetcher: typeof fetch) => api.mixes(fetcher), + path: '/api/mixes', + body: [{ id: 12, name: 'Orchard Blend' }] + }, + { + label: 'products', + call: (fetcher: typeof fetch) => api.products(fetcher), + path: '/api/products', + body: [{ id: 8, name: 'Packhouse Bag' }] + }, + { + label: 'product costs', + call: (fetcher: typeof fetch) => api.productCosts(fetcher), + path: '/api/powerbi/product-costs', + body: [{ product_id: 8, finished_product_delivered: 14.2 }] + } + ])('uses the injected fetch for $label reads', async ({ call, path, body }) => { + const globalFetch = vi.fn(() => { + throw new Error('global fetch should not be used'); + }) as typeof fetch; + const injectedFetch = vi.fn(async () => jsonResponse(body)) as typeof fetch; + + globalThis.fetch = globalFetch; + + await expect(call(injectedFetch)).resolves.toEqual(body); + expect(injectedFetch).toHaveBeenCalledTimes(1); + expect(injectedFetch.mock.calls[0]?.[0]).toBe(`http://127.0.0.1:8000${path}`); + expect(globalFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4cea178..8914972 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -28,9 +28,27 @@ import type { } from '$lib/types'; import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; -const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000'; +const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000'; type AuthMode = 'none' | 'client' | 'admin'; +type ApiFetch = typeof fetch; + +function getApiBaseUrl() { + const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim(); + if (configuredBaseUrl) { + return configuredBaseUrl.replace(/\/+$/, ''); + } + + if (browser) { + return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_API_PORT}`; + } + + return `http://127.0.0.1:${DEFAULT_API_PORT}`; +} + +function buildApiUrl(path: string) { + return `${getApiBaseUrl()}${path}`; +} function getToken(auth: AuthMode) { if (!browser) { @@ -48,10 +66,10 @@ function getToken(auth: AuthMode) { return null; } -async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none'): Promise { +async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise { try { const token = getToken(auth); - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetcher(buildApiUrl(path), { headers: token ? { Authorization: `Bearer ${token}` } : undefined }); if (!response.ok) { @@ -69,9 +87,14 @@ async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none'): } } -async function request(path: string, options: RequestInit, auth: AuthMode = 'none'): Promise { +async function request( + path: string, + options: RequestInit, + auth: AuthMode = 'none', + fetcher: ApiFetch = fetch +): Promise { const token = getToken(auth); - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetcher(buildApiUrl(path), { headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -97,15 +120,17 @@ async function request(path: string, options: RequestInit, auth: AuthMode = ' } export const api = { - rawMaterials: () => fetchJson('/api/raw-materials', mockRawMaterials, 'client'), - mixes: () => fetchJson('/api/mixes', mockMixes, 'client'), - mix: (mixId: number) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client'), - products: () => fetchJson('/api/products', mockProducts, 'client'), - productCosts: () => fetchJson('/api/powerbi/product-costs', mockCosts, 'client'), - scenarios: () => fetchJson('/api/scenarios', mockScenarios, 'client'), - clientAccess: () => fetchJson('/api/client-access', mockClientAccess, 'admin'), - clientAccessExport: () => fetchJson('/api/powerbi/client-access', mockClientAccessExport, 'admin'), - dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', [], 'client'), + rawMaterials: (fetcher?: ApiFetch) => fetchJson('/api/raw-materials', mockRawMaterials, 'client', fetcher), + mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher), + mix: (mixId: number, fetcher?: ApiFetch) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher), + products: (fetcher?: ApiFetch) => fetchJson('/api/products', mockProducts, 'client', fetcher), + productCosts: (fetcher?: ApiFetch) => + fetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), + scenarios: (fetcher?: ApiFetch) => fetchJson('/api/scenarios', mockScenarios, 'client', fetcher), + clientAccess: (fetcher?: ApiFetch) => fetchJson('/api/client-access', mockClientAccess, 'admin', fetcher), + clientAccessExport: (fetcher?: ApiFetch) => + fetchJson('/api/powerbi/client-access', mockClientAccessExport, 'admin', fetcher), + dataQuality: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/data-quality-issues', [], 'client', fetcher), clientLogin: (email: string, password: string) => request('/api/auth/client/login', { method: 'POST', diff --git a/frontend/src/lib/components/AdminShell.svelte b/frontend/src/lib/components/AdminShell.svelte index ce9a4ba..b823689 100644 --- a/frontend/src/lib/components/AdminShell.svelte +++ b/frontend/src/lib/components/AdminShell.svelte @@ -1,6 +1,7 @@ @@ -68,7 +99,15 @@

{pageTitle(page.url.pathname)}

- {#if $adminSession} + {#if !$sessionHydrated} +
+ A +
+ Checking saved session + Restoring admin access +
+
+ {:else if $adminSession}
{initials($adminSession.name)}
@@ -88,7 +127,13 @@
- {#if isProtectedRoute && !$adminSession} + {#if isProtectedRoute && (!$sessionHydrated || isRestoringSession)} +
+

Checking Session

+

Restoring the Lean 101 admin workspace.

+

Refreshing the current route with the saved operator session before prompting for sign-in.

+
+ {:else if isProtectedRoute && !$adminSession}

Restricted

Sign in through the Lean 101 Admin Panel to continue.

@@ -284,6 +329,10 @@ box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08); } + .loading-card { + min-height: 10rem; + } + .locked-card h2, .locked-card p { margin: 0; diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index e7e9e84..6c88d17 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -1,8 +1,10 @@ @@ -154,65 +227,79 @@
-
- - + +{#if showBottomNav} + + + {#if navOpen} + + {/if} +{/if} + {#if paletteOpen}