From 2f2466ecac79522f63cba131b4f815cf54bbde52 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 10 May 2026 09:46:07 +1200 Subject: [PATCH] Updates --- .env.alpha.example | 25 +- .env.example | 33 ++ .env.production.example | 23 +- .gitignore | 9 +- CLAUDE.MD | 109 +++++ Imagotipo-azul.png | Bin 35780 -> 0 bytes README.md | 25 ++ backend/Dockerfile | 9 +- backend/app/api/access.py | 36 +- backend/app/api/auth.py | 53 ++- backend/app/api/client_access.py | 5 +- backend/app/api/dashboard.py | 4 +- backend/app/api/deps.py | 21 +- backend/app/api/mix_calculator.py | 5 +- backend/app/api/mixes.py | 10 +- backend/app/api/products.py | 11 +- backend/app/api/raw_materials.py | 17 +- backend/app/api/scenarios.py | 10 +- backend/app/core/access.py | 18 +- backend/app/core/config.py | 70 +++- backend/app/core/http.py | 51 +++ backend/app/core/logging.py | 372 ++++++++++++++++++ backend/app/core/rate_limit.py | 39 ++ backend/app/core/security_logging.py | 15 + backend/app/db/migrations.py | 31 +- backend/app/main.py | 223 ++++++++++- backend/app/models/product.py | 2 +- backend/app/schemas/client_access.py | 14 +- backend/app/schemas/mix.py | 21 +- backend/app/schemas/mix_calculator.py | 10 +- backend/app/schemas/product.py | 29 +- backend/app/schemas/raw_material.py | 20 +- backend/app/schemas/scenario.py | 5 +- backend/app/seed.py | 14 + .../app/services/mix_calculator_service.py | 7 +- backend/pyproject.toml | 1 + backend/tests/test_costing_engine.py | 135 +++++-- deploy/nginx/clients.lean-101.conf | 3 +- docker-compose.production.yml | 11 + docker-compose.yml | 11 + frontend/Dockerfile | 8 + frontend/package-lock.json | 10 +- frontend/package.json | 4 +- frontend/src/lib/api.test.ts | 2 +- frontend/src/lib/api.ts | 80 ++-- frontend/src/lib/components/AdminShell.svelte | 27 +- frontend/src/lib/components/AuthGate.svelte | 51 +++ .../src/lib/components/ClientShell.svelte | 319 +++++++++------ frontend/src/lib/components/Toast.svelte | 2 +- .../mix-calculator/MixCalculatorEditor.svelte | 100 +++-- .../navigation/ClientPrimaryRail.svelte | 28 +- .../components/navigation/ClientTopbar.svelte | 24 +- frontend/src/lib/session.ts | 9 +- frontend/src/lib/types.ts | 3 +- frontend/src/lib/workspace-access.test.ts | 38 ++ frontend/src/lib/workspace-access.ts | 194 +++++++++ frontend/src/routes/+error.svelte | 244 ++++++++++++ frontend/src/routes/+page.svelte | 62 ++- frontend/src/routes/+page.ts | 15 +- frontend/src/routes/admin/+page.svelte | 4 +- frontend/src/routes/client-access/+page.ts | 11 +- frontend/src/routes/mix-calculator/+page.ts | 6 +- .../src/routes/mix-calculator/[id]/+page.ts | 13 +- .../routes/mix-calculator/[id]/print/+page.ts | 8 +- .../src/routes/mix-calculator/new/+page.ts | 7 +- frontend/src/routes/mixes/+page.ts | 7 +- frontend/src/routes/mixes/[id]/+page.ts | 12 +- frontend/src/routes/mixes/new/+page.ts | 10 +- frontend/src/routes/products/+page.ts | 9 +- frontend/src/routes/raw-materials/+page.ts | 13 +- frontend/src/routes/reporting/+page.ts | 16 + frontend/src/routes/root-access.test.ts | 49 +++ frontend/src/routes/scenarios/+page.ts | 5 + frontend/src/routes/settings/+page.svelte | 1 - frontend/src/routes/settings/+page.ts | 20 + .../routes/settings/settings-access.test.ts | 35 ++ frontend/static/robots.txt | 2 + frontend/vite.config.ts | 29 +- lean101-isotipo.png | Bin 14926 -> 0 bytes logo - v.png | Bin 17065 -> 0 bytes logo-hsf.png | Bin 710075 -> 0 bytes 81 files changed, 2571 insertions(+), 413 deletions(-) create mode 100644 .env.example delete mode 100644 Imagotipo-azul.png create mode 100644 backend/app/core/http.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/rate_limit.py create mode 100644 backend/app/core/security_logging.py create mode 100644 frontend/src/lib/components/AuthGate.svelte create mode 100644 frontend/src/lib/workspace-access.test.ts create mode 100644 frontend/src/lib/workspace-access.ts create mode 100644 frontend/src/routes/+error.svelte create mode 100644 frontend/src/routes/reporting/+page.ts create mode 100644 frontend/src/routes/root-access.test.ts create mode 100644 frontend/src/routes/settings/+page.ts create mode 100644 frontend/src/routes/settings/settings-access.test.ts create mode 100644 frontend/static/robots.txt delete mode 100644 lean101-isotipo.png delete mode 100644 logo - v.png delete mode 100644 logo-hsf.png diff --git a/.env.alpha.example b/.env.alpha.example index b828df4..edae4ee 100644 --- a/.env.alpha.example +++ b/.env.alpha.example @@ -1,17 +1,28 @@ APP_NAME=Lean 101 Clients API +APP_ENV=alpha CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com -CLIENT_PASSWORD=changeme +CLIENT_PASSWORD=replace-with-strong-password CLIENT_TENANT_ID=hunter-premium-produce ADMIN_NAME=Lean 101 -ADMIN_EMAIL=admin@lean101.local -ADMIN_PASSWORD=lean101-admin -AUTH_SECRET=replace-with-a-long-random-secret -ORIGIN=https://clients.lean-101.com.au -PUBLIC_API_BASE_URL=https://clients.lean-101.com.au +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=replace-with-strong-password +AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret +ORIGIN=https://clients.example.com +PUBLIC_API_BASE_URL=https://clients.example.com INTERNAL_API_BASE_URL=http://backend:8000 -CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au +CORS_ALLOW_ORIGINS=https://clients.example.com +CORS_ALLOW_ORIGIN_REGEX= +TRUSTED_HOSTS=clients.example.com CLIENTS_APP_PORT=8081 +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_DOMAIN= +SESSION_TTL_SECONDS=43200 +REQUEST_BODY_MAX_BYTES=1048576 +LOGIN_RATE_LIMIT_ATTEMPTS=8 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +DOCS_ENABLED=false PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false DATABASE_URL=sqlite:////data/data_entry_app.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dfb20e3 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +APP_NAME=Lean 101 Clients API +APP_ENV=production +CLIENT_NAME=Hunter Premium Produce +CLIENT_EMAIL=operator@example.com +CLIENT_PASSWORD=replace-with-a-strong-client-password +CLIENT_TENANT_ID=hunter-premium-produce +ADMIN_NAME=Lean 101 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=replace-with-a-strong-admin-password +AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret + +POSTGRES_USER=lean101_app +POSTGRES_PASSWORD=replace-with-a-long-random-database-password +POSTGRES_DB=lean101 + +ORIGIN=https://clients.example.com +PUBLIC_API_BASE_URL=https://clients.example.com +INTERNAL_API_BASE_URL=http://backend:8000 +CORS_ALLOW_ORIGINS=https://clients.example.com +CORS_ALLOW_ORIGIN_REGEX= +TRUSTED_HOSTS=clients.example.com,localhost,127.0.0.1 + +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_DOMAIN= +SESSION_TTL_SECONDS=43200 +REQUEST_BODY_MAX_BYTES=1048576 +LOGIN_RATE_LIMIT_ATTEMPTS=8 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +DOCS_ENABLED=false + +PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false +PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false diff --git a/.env.production.example b/.env.production.example index d47a5e0..8eada0e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,26 +1,37 @@ APP_NAME=Lean 101 Clients API +APP_ENV=production CLIENT_NAME=Hunter Premium Produce CLIENT_EMAIL=operator@example.com CLIENT_PASSWORD=replace-with-strong-password CLIENT_TENANT_ID=hunter-premium-produce ADMIN_NAME=Lean 101 -ADMIN_EMAIL=admin@lean101.local +ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=replace-with-strong-password -AUTH_SECRET=replace-with-a-long-random-secret +AUTH_SECRET=replace-with-a-32-character-or-longer-random-secret # Postgres credentials. The compose file builds DATABASE_URL from these # so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL # only if you want to point at a managed Postgres outside the compose stack. -POSTGRES_USER=lean101 +POSTGRES_USER=lean101_app POSTGRES_PASSWORD=replace-with-a-long-random-password POSTGRES_DB=lean101 # DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME -ORIGIN=https://clients.lean-101.com.au -PUBLIC_API_BASE_URL=https://clients.lean-101.com.au +ORIGIN=https://clients.example.com +PUBLIC_API_BASE_URL=https://clients.example.com INTERNAL_API_BASE_URL=http://backend:8000 -CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au +CORS_ALLOW_ORIGINS=https://clients.example.com +CORS_ALLOW_ORIGIN_REGEX= +TRUSTED_HOSTS=clients.example.com CLIENTS_APP_PORT=8081 +SESSION_COOKIE_SECURE=true +SESSION_COOKIE_SAMESITE=lax +SESSION_COOKIE_DOMAIN= +SESSION_TTL_SECONDS=43200 +REQUEST_BODY_MAX_BYTES=1048576 +LOGIN_RATE_LIMIT_ATTEMPTS=8 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 +DOCS_ENABLED=false PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false diff --git a/.gitignore b/.gitignore index 3975dc2..6d59095 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,20 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +*.egg-info/ +*.log dist/ build/ node_modules/ .svelte-kit/ backend/.venv/ backend/.pytest_cache/ +backend/.tmp/ +backend/pytest-cache-files-*/ +backend/tests/__pycache__/ frontend/node_modules/ +frontend/.vite/ +frontend/coverage/ *.pyc *.pyo *.pyd @@ -17,4 +24,4 @@ frontend/node_modules/ *.db .env.production .env.alpha - +.env diff --git a/CLAUDE.MD b/CLAUDE.MD index 52bf0a3..0f7a4a1 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -1,3 +1,112 @@ +## Repository operations + +### Dependencies + +Current app dependency entry points: + +| Area | File | Notes | +| --- | --- | --- | +| Frontend runtime + tooling | `frontend/package.json` | SvelteKit app, Vite build, Vitest tests | +| Frontend lockfile | `frontend/package-lock.json` | Generated by npm, commit this with dependency changes | +| Backend runtime + tooling | `backend/pyproject.toml` | FastAPI app, SQLAlchemy, pytest, packaging metadata | + +Current declared dependencies: + +#### Frontend + +- Runtime: `lucide-svelte` +- Dev/build: `@sveltejs/adapter-auto`, `@sveltejs/adapter-node`, `@sveltejs/kit`, `svelte`, `typescript`, `vite`, `vitest` + +#### Backend + +- Runtime/tooling: `fastapi`, `openpyxl`, `rich`, `uvicorn[standard]`, `sqlalchemy`, `pydantic`, `psycopg[binary]`, `reportlab` +- Test dependency: `pytest` + +### Dependency update workflow + +Use a small, controlled update flow rather than bulk-upgrading everything immediately before production. + +#### Frontend + +Check what is outdated: + +```bash +cd frontend +npm outdated +``` + +Install targeted upgrades: + +```bash +npm install @latest +``` + +For a broader refresh within `package.json` ranges: + +```bash +npm update +``` + +Then verify: + +```bash +npm run test +npm run build +``` + +#### Backend + +Check current declared versions in: + +```bash +backend/pyproject.toml +``` + +Upgrade by editing version ranges in `backend/pyproject.toml`, then reinstall: + +```bash +cd backend +pip install -e . +pytest +``` + +If a backend dependency is high-risk near production, prefer upgrading one package at a time and re-running API tests after each change. + +### Repository hygiene + +The repo should keep source code and deployment assets, but not generated local artifacts. + +Expected long-lived top-level folders: + +- `backend/` +- `frontend/` +- `deploy/` + +Expected long-lived top-level docs/config files: + +- `README.md` +- `CLAUDE.MD` +- `docker-compose*.yml` +- `.env*.example` + +Files that should stay out of version control or be moved out of the project root over time: + +- SQLite databases such as `data_entry_app.db` +- local cache folders such as `.pytest_cache/` and `pytest-cache-files-*` +- virtual environments such as `.venv/` +- one-off working assets such as loose spreadsheets, image exports, or temporary notes unless they are intentional project deliverables + +### Tests and pytest files + +There are not many real pytest source files in this repo right now. + +Current actual backend tests: + +- `backend/tests/test_access.py` +- `backend/tests/test_costing_engine.py` + +Most of the extra `pytest`-named items are generated cache/temp directories from local test runs, not hand-written test suites. + ## Spreadsheet analysis summary The workbook is effectively a costing and pricing model with three core calculation layers: diff --git a/Imagotipo-azul.png b/Imagotipo-azul.png deleted file mode 100644 index 2c6ca39860cfa70f758f303879f5e6abd2480a26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35780 zcmeGE_di_U^8k)tC5RFPA$myEXwiF%kVHvzQA46c^j_8~&yc7Q5iKmy5;a0}c14ho zSP7!r)uLN{vBl@&`FwxBpMT-=c-{`6iL0wUGswf zVIt{8AKmiy@-lNrsIy%)yGz)CxPMkv54>xdF*N+SeBbaqIQ##e|4HC~68N74{wIO| zN#K7H_@4y+CxQPjN+5qbM5rJHb|8xr!3?5 zv3QhPlby3==vm*;^&!L+(EdfGD> zva5hT>gxq`noQdZjm^}*XKx+!$Rn4_?+4iY*^AQ6J|imf3IkAr=@u@1re?y!b_y5S z&IC9Mm4Kou%|2Uz$o7kXvz{ZR_ZoS7Wpd5G=<>bDBQL+}TzNi@$XFpeLpLBuil804 z-|JRjG)#GtkN`kFJ$dAn;FA`t79WHL<` zenm{?bSz-Y574U1-!4Bwoc%q(NQ&638zl*U1ui)ixBG>&*W-o&Tb_Jb@@li%rk>xw zpynW`@7(J_E!c;D<)XoH9_Tze;Y{#9&g2+@*q@-JKvJ5_{u4kw0}#8;Sz^O;papR_ z!?te#u=Qq|>aJ0!{T~0#!xcQUsDwm|+s09L$gKpx%mpI`*2fKJb(GX5uI zx=HB&Rx|%9iNk10;R%2Lt-FRWDdMBO;Qv2=9w5iWt~373|Krbt3eXNqG^wHwFZfRf zb3m5&vrK!|KmGTnLK9#c7yhjUD_S-fqE!3Mc}8OEKY(Q21jyj(5rY5c*?JzfT{u><+NcxXF47llmzPlH7hLMUC zF#yp$(aQg%?Jx>rQ#UN*|E*;tb7qMMNe~@8v22bHv^Uw^f`8{_yF}u!RO-{)5+zae z>`VvQE`YBZZa?@xoy3C3jvXmCZ)Z>6{QoW9ZU!Jp+XM2J;Z|@)$I>+t2e!Nyyl0lG zp$X=C3%O8ACZ-Ra-{E!O3 zg3=ur(cB3*+N(R6>sxwbaS7Vp1aYio+Gm_`I>944DORV{(K9$aKoVe^W`hkfHtyH2-;gz zwM^B*cQB#Kx<{s@omL(8-5qLW6kBNTA-%8gzDKp8JtxqwOODg2s=HKj2-9p9SW+lU z{1fHFDjA9CseR4bQz6~1PzE=d(IJm~*r?`Cn(q>`$R=Y{uh6NS^ey>l#qA<~y{uMg z5a>K?P5yl(Y!72vWM-H}MiZ7Dn{q}S>jl~-R@j?@B!7gts^oaoF8ykxSz6rb+7-Al zVj!>5`B1eKK!R@dVSFxpF`Hy5YwHd4SdZybOkA7g`%O)4|2M1hDml(?g7+s7Mf>P< z?x3#=)03n7J2)hj^$TI0W2JyN2Ra~avH7e6*4}X?9WDbqUgp_~3&XBf% z9oqeCB3q(3JgQSQ(ulCS#r}$9N?F3B>8bqvvc*x(HxXvg6z)GrV^FR1Klcau|9BAr z*+m}Xuux?%Xc&;0OffPU(-GUUc@o14zY5THUX*{9LeUQx#mDtNbI#W^R>#hTML%>6 z9l5Xn1X{Q0`tm}cH*xgxMYjZ0&JaZ)pTwKSr)XvIZe)8;>|n}lsIL7{km?4v{O)x* z;zodpDk}rY%<&Lpqn}gc+>w8kyPX>D?rSDS;BN-FRBX<|lo6~adr00d{054pCDP4! zcTSH+)ku+HErh{!r9C9O$lbZNy_Wibn7R)Wb2H`9g<=;$ul~=R+L1*7Pn&PYXg<_l zAmRBK(}{0l%j66jpadd6>j&;C=^9&qBAZ)jtUk(4yr*IHbNy){iXd>*#}fd*{24#R z{immH`WmNSrTrUt3v7Eef*Z+npWgPo(Ecre?a0keeH1Bs4$#>0xkSKv>Q*x`X-OR_ z4$iM3WK@R+hZZBn;P=;_I;|78#TRq}Z7)3M8t&2AlRi(eV+w`qwOHSyHUc#!B1Sk!!e2bcjbv$2K>SYwHsx)?~q=ab4L*{t8>j>6G`@8{4aIDQDJl8KTrHacXt(T!;aKgq2`>I=%?`mjK{uL;iP~Dv?%; zymP;%a5<*LCFLEmr)Z(l_AbH*L{p9Hnd5?Q@Q(>CyrRKwRJLv4DEn+t(eS$0ASSVd zM|*4l?Ub&;$H5wlB8Z)9e8+*GQ8fosY1~S8jZ5Kc!SX`!y&59bbr9Deg#QwQPWwRbGi1Zb>^; z-W{mDBon(w0S_cT_4w=@JUN=ugEj&C{*uz(xBI4K+eKDvjL z4nCxr{tgqT5R<(lwXE3IR3)epMVz8pe>(S}UCJ=zKEl*V#@9=Akh$`hmU9>u1!`Bu zdlK4psk@ALRcPA)7)^aNxfXcb9jqo0SATm^{`Me;tc6>x4;YssR~IcHEJwT&YtKXB z(H!9@c|()w%5k~-+E{l2?pW;zQ+_OW2en~JtkxxjP1c)N#jIh5DfTZ%z5)&Z*!eb^ z6#(|#jM1DtG`XwqBW^{?<&?yoqd5}wKN@VE9J|yKG0eZ<1Ak6rNFePFix4c2+_2xn z1nK;}w}T9_JPtpuIu5Dpfl1pPk3)uCfIFbA5nkjpA-oJ5ff1YiH&i( zkQ=_CJJ&pTfC$j(EXN*9+L#iE+IwO8mPpz2{u|uE*=tYddLmL|QgPPI4A?Y@K2%P& z=#R_S|L)ZsHG#Pcj;J@`5-|3+{YZC@IKBEjhDfUcM$jL_YGvvWUhQt(fQb1}m&3|t zsr5bc-d#xH z&=)&3vO%tJFP*=<30|&m0m-#~m4ycvI)mA!Jzx57c4~jybxt)G1)Lng)TW-Nw!6{K z7_FbuQ(F6aaL$vzdtgzVfy?jpHfgL;AJP3c`*5H7T5t+_Uvq z*V?|wnYHq(Y`CZib;~Zj0g!aTd6t4ug6%+AUP-5n$L{gIUnUuN@-YBjVUcFxN&+C7 zr&$zwGQ5alMgwB?+?c;FgqF&JofDK|M~-gPAv#+P(!jC#$A2+s3ELHp)C=LCVk~k> zYLS|b zv}>5b4fFF6>QoQTMVB^n1Au$tW8Dw>>~L7R;GrqH6=QQXlvmBEp%0PvtmR`hZc1cf zr>Mi?MVAqro)2dWKkC+;l22xjDArk-uyK2I^+y61AH$^opEqU0DygyXchkQw?+=iaRo|L4DLTd_%w8GPZ?#6*XYBm&eDT@`r^42#&g#mRl=$=amhM z0`}{*ce?M?Q6n_Zqio04`{k}RHxJ8S2po`qXT)g%DTF7G`SL16hcQxlZo4F|23(=C z`FO1R=9RO%9k>h0_dfh+1gd|VPVXTu?&1O0i2OG_E}17|EgZ$Id>fype}PfI(cwKb zr+vhcykaplY{{WcQa0ofNXlO&in6;o3oed0 zV_vwg0N3sxg8I0`Co4I{OIgL7c(azQHqgX>cKyUd+Tz#Cu<9PG zNqB-opK>s%7m6Xb_p%lvFS_L)``0WL-0!6csur7kc5B0@WqfS^3B%4q0&@acHZ@<> zC5l$A`KnEPR&aXuU7AXP%|KMjR$FjRw$UNp&YXN)17Q2pphq8t8q?tBG+fP@D{{gR zuP%g|Fil+oB6aT!jX*Z6^7YwWY1qdqyt=E>P;Yb%?;fhj=4AUa%{`hCf4%py)^Bnt zNZ;_VbpV~a#?Shp>98%Nws?`dWg2q_IRDaYnRvWwligr5V zz?&3)*v&@vrGDseYDj2-{W8TZ0I1NXeml1#aqMNcXYWXA%I<0>5;#w=!zg8`uOuWa zNIx|+I21UYf2%dbYet+Iry|=_D~)+tn6pihQqu0O{%2upEi}CG_^-_9&+CW>!{qgm zD~}=S?W{Sp#J{_QV*37_NeOVKt?sHVyt(E~;-RNeP}4PlAEu z?vnwLGs5>RusaDv(}?8Jz#Fa=h9XGIB9%kpc6%UEN#GqnL-_P3*fk^P#b+%?suiUl z5*cfcl)L-utDbm*Nz_Ax4ZnIAD(RVp!CIbr#b9VH4%5RH)K=DGI&cRpaO_B6QiMzU zv*{l3HkR##!D3S#HyjiQQY<%RE#$G5EAspS2*@Jtn|eVYB-)Ykwals zWnnamXjBez8`Beo8tkC;+ySnT!Uf4%{}#Ts$gqEKGAGqF(hot%k}-n$3z-2P8>PID zqoFgtRg9C}M+nM2zZH+%X0C(JuRcTPS`hv95w7oAC(Itm?~s>=ZK%jHk*k#lg$}Ch zRV~wkskbLOsI{W{HD$E6{e7;q^H}o?GGN#4MQAf@l&4PQf{>s)r<&G{- zyddcN@l)=DIqopBgJPY6ov+wlQRRtH-VAlown*oa>E%DziPo^@wKloG(h@)1m<*3s2%!xkZ=#fU-sAHjDYj= zSoQ<12e~MNBx5@%mjFdT1OA$g%--VwzuAOvH?q;n3vL&yChSFFR&zZNb74b)sB2zD z;T!1*Ulwy{h?n3aa$ssKXSx&k4VAJu{5kaTLt5Lbz>fj$#5mRi+v%eW`flyYot6)5 z*R8-+7~r|KvgB!wlsDQGs}El%3%EDd6gWbe92$oeXrB4C7*Lyw+j-<-4WFmuy(TaF zRpD=CtjE0H#7%}#o4+Y)o|{4K;y8#M17$8a$GsoMRrcUEg9$#=cx%$$xaH;iszRI+ zSUPG{5YW`M&byY!Uq7b0`2k_HU7GOSuC7IGGa$FaZCM!p3RoB18UzduKsVR(U3w=+IuX&8Qo;SFW(K6i&y`@6-yBO(exPT9D zI~wk&PCirI3O@w7P#Y5$8h#=07aHz7NLr;@*YXq#HTGYz? zKzJ2BQ@*P>RhttY@_!0FAe=_Jc<^Z$)sMUubZ$CMW?;<%ES+Wh@-})XJDmiwM$~zO z{b9`qmy>cmtBo%_`oY7sl?8!qzs<_^fTY!qr0Wy#?bC|iJ=I#aLULJD=CW=P3X-Z< zw)%Vskb?u-4Yf%;|8O3m0g?SY%wq zP)$VgmK}4pP>RgJ#Kw=qm%WA;zBq6DVJAZ4?(5&+sh`i~DzZ72R1@v79+v&7K2eZh zpsQZU*vQn%(H|_>j=NJrP;Jn2kVsy|8NLWZ&iJ+~P`(K{@Wf~Peza&A2nz%TSwr^v z2LkbbNv2bk;9*Q;s}o7!;Y0wt?|g%{_c$_NDJq3t5}#)q92 zqhI^1eeR4S*a+b<3_I8ojr|=H4Ix`%BXMXJq2N#LUH)ml{I*%~y|QJMLo%ghK*YqOx7$Tu zo}~u>K;`{+b{p@!2`v)m`nURvi_^zJb53X9t2d-ME_d0u-!>XL8%@e}|Hj^qJqMo4hTkyDrN9W3`vYmZN@x0(c`0RU&lckk%Kl5%g$)dquA z`B#uw^8~c!qm}qX+x0R{r8}uQqAuRq;kCi?D+EI?$C2u;KN>;vi0Pt%vA-)_^>;p) z4iv9ylW|=E^fTt!sT8MnMl=cY^Z^lf|Lo)|Hkq5+)MnlU3yeHYOZP>XzPdTGI=%3V z@J@AW+y6tEjEVhLv8O&9)O9SD@}BkW;G@vL6wO7Z4qj$qZ}um(=dxelIj34f!xjrV zx~k_aoz*MD+nojdUL#$y3F!+N6jlJBY{5s^vDJ2CfY;40#!R_-K^!7CL|Smzv>{|= zE3`X#Eu^4!>>Mo_KcLV0HD)$PlTOR`=aqm69KXQgMuu?fNnLd`{i5X5!P>p8nMi)adpOLkbWUqg@rK0tPCh_?;%6DjH(WO~)_FY0jp(;qN@Ge|BkRNHuu$jYWQPFKJxv*z@>oscD-6yW$)TG+qdiEexa=R7Iu za;~4b1NDL8*$jA)@pD=t2HttOu`6GU6uyGrx27GCc=Mrc=j&AfSV*Tzw3BUR*z=3> z8~v^s^tnMzQiD~WI@iU{MWA5nPWru%!3-2&v2uTUDApJg6e?Y{ioeynn0i;8?Igdp z&#WT@ggn8-55@tB3z6@6hsY|5Dw$}nq`%HUyLp3$I#<_Y-*Zxw^;y-hp0n0T&3W8a z?s+p4xRU$_%7J5)q7=6Gc`T{H80^N}8r5^p7R1JCa$x7{_fq%{ozfld6*Bmp$G>ez zcS9IV^&F8u=4KVH-f7}uHd%|+3H|sgxQEDeoi3L18;@q zY`OEyayHsLF%n?QGpa!gI1si16Ke#|_3wrVV&qGAT134|UL+V*&mSQe$&(#6wPFCg z>YvNSH?qjriR&ds&-(deXlWxsx_oVnB6oefFNv1(ZI<^-NHCHe;uZ{jPQu$i_up}E zIQB5TyC$$*v|R`TkHqw7Dna4Px&1nbnL@F$e)u7d=1)3L5CrtCf2>lrM0^j3kT9v* z30t~xjCSvJYg74lY_rA;uMSEk12$VPMh%p6!51%9vm&|j z?7*eo@E?GJEb73Y7^(~%wi%h5R{mIB<#cN$n$gKX%afsEgP{v3s5KFbKTa9k5S$(t z2?Xqi+zRM^fE7LNm=xoz*Uw==N9}&94Hup4Kj4?-!8x3AE!Sj4yahmELevwtuUxzH z{2MQsD%QwAgJ!q<@6GATkT*jIbP2Cx+Aa3J{_!Kr98N|n^snyr=6QrWEMlt@|r zd40iuInaO%2rvKorLQH_*kh!fIrw-Bi&~|?tA_=pO?4-U)3OnA^X&Jiv+XqJ3vLxy zFogJ)mY6#{IrqxkA@tn4k|4*BbI!LoA6RLQiZ(WjE~VUy;>r@ggV(Gf<}Gq6z2M4- ze=ouu$r}7Cr0byL`d*vUpQ0!&l2ASiglCzMwFJeM*JrM%3`-JyCV!_rObQOauX@lD z{6s^)e_Ew@e_Gzss^}(ArC4iPtG)GJi`~K|;RERU&Ot5tX;R%lvhl%m5cCdsh{@nvaeTfKefMJQSp(FU8yP&HJib{&F>+pD8V={iD!4 zAbWO%wub?~{zq9tmwm}3;ZA-FdE^I^w*?bbsp0Qrrp>G&c3_13=sTwXmE*kLiCje{n8u{xZ>XCNJS z%ba0bfqQ1l&J#)5^?*zzS!F^kBm$NoO^#AIXPHxT!!f6ypqw-z2z!IwW(@<6jk(hK zxf}=0zlQgFC(?V;9ek)hcv;U7%JcLJdEMP)mh9i^K|52IdfZ$nxgYa}(fX$Wk^xVg zfrDy)L9@jf32)a>PeDk);<@vrx_myuo>aJxNxwmwX$&0`d# zlHwO%@v{ilqn1SzT2j;|ee5Sb@|IBU-gj*>1|F(TRUG!v+=V z?$6;mz=~ap0tt-eJ5XK;A)A>RtXOO>iV@zY+}O!8s=1H-*+~!7HPZ1+NXO9X9!-A3 zSm=NI!AMR-J4fZ_wcUO`afL)Cjs@{_ZXi)k&@GH5UmL7ZHbhWDSqnxNv9_z&!zs8Q zlnbP5aCw_}Tyzlt9*F2V>=eu}@xq-Ix|@DTeDqWIVJDA@`m3M33|FPi9MWmhLmc*S^u0b|AStspwDQbX;$r?TT= zA5lA{aN42r%&!4UH>-p-1FI=^q}uk4RmN}Lt= zsT{0=ngn=IoQZf-NVwtA(eN{s;;1l(V&C`jQPX3F0Zzr=>JrRO^z&8=1bga>%{oA4 zGrqhYW<%c6zFX1iQs8x>d=A;Md}6x>M(tY;hfTgSB$1a6#X6-ntZ!Vo(Ic{864u;d z2kErb4L4`dNb+rk9~ZNQ%?2KbNS&nN-qs}UohY@7#U#T6A9;@(l+lOr$fNn|9#fuzD zzq|qyGk&lXRDcuqqkRGl1o%ik%A5{?icdTni7!g`U31}>==FHQzo;-m@5wbih&dKo8|Sz*5FDoJgkqa3}_&KqvLgXL2#aCYLy7BH5-#Gc_E$qxVDu2>tuy zaHGYHwlaYfugDj5RBuD(lhMtSdxq7ADYYAiti!=hQWjy^rUeAIqL|FK-W$^3HME zG`FVT$GDRX%?voa-Rg+={>x70A%hYa-l;7=iJ$1&-XRK`)mTA>6FTqbSG&cMm(LIh+)Us5Juz|JO2=Gt^B0b}s(&IRU^p?PY@Mu=MryY59 zbg@A=soYKIw-(BOo`dlB9qE8JTqHVHbibPGk-~3VQTXxvQ^xH_utT&nY2@@r(o9=A zW)lB6u<~qCw7c4?kF^pag=g#^db|X+Ei)ZjE3bp^AZ!6py*-I0nU!{5{#;ECVm z#|quHeSN$m^%4Sj62N~@pAirxMeJ(kn2dLuwP(1&>?Jnqh4v}!il2GT-B~Y4pwd8w zn~~F*uUY3f4X@NKc>EZxI&l?CC8UvdFI8XK65dheD}(MK=HXpr7--R7uRUF5!5ckT z`q0e{$c(q~xz=$qDe)-1x-Hlv@^?@&kABh|7zo)1oGt39qb`8%U?SeM-XqMXi$o8H z57enq-_&unc(4>jL#Y9q{!>5d>K~?7zi?s(P5d5xqZZ$O>9RF7fgLN&AjehigLWke z_ttiB)4Y!;S%h0rv4Is7+>H*v?$^%2BIWGsBN3l%A1vg~wS;@SlV}uLy*Q811j%jI zyxZ>wMljq5?y{T3ixdF+FJfpp8@ohL#Ahs5Uqu=}-7fFryQDz&_b~Rz7%E^e04|5e z&uR>Xi5i8LjE)>?X#yHz9@TC_pQ>D;vbHrw9?2cZYA*M?{aSgII%>_|0YGU4MH0Ln zqyv0ze>sPqi+Q(H@O?~%?unf+Ef5$Uhc~z~z*KnMV`Fq=*fQAOUK4HD+DGfyS zvk8$pbWuArSAD=eZe_(^YtqBI!8u&J&~qLKKEJz%x3P1NULXrbOMhLz1uPj&@LJ%O z+y(mt_pf#D#L*IR*&fMXVFa>W3a9{ue^?OS;qq=MaVp{WW0;|^xkJ~()r@=Xj_O1^ z{WOBh1D5dF5|EB>HR|h*V}DkBy`_6Z(0=QpfNRk+J^;-?itx6f7hadZ7iTg^uKJu0 zAD=xA;ilqlaRc&qe2WA&j(Y##DAHxYwWQ+%XnO(*s7BEx8K`D(@5?X~+&U)I;lrRM z>79TEcARoixt?CGY1LoRp#Rbp48V%u~gN)0oYx5&-GWuK!sj z{f|K81)=@IpQ*17HV`{({{+%zt2rz6yB;$&gA+$^Uq zg0rWM&*AR=_^3TC@qs2C;9*u|+t2t<>%^RkeUE)u#5uL*^CbBu`mZn+)=Ij{@8Y`u_$8VbX|Wcj91UOqYS|E4mt<`D|fM z!Dy7dkk%5|L)~pMgZ{v|5FaB2@Uo6ZgbS1`K(o&alBny1Bm%2I4ZJ zPl6V(Ei9kAE21`_6iQsv!aphkW#gPys}ox|%2&?gYvUrEU*H6i2>|c!RuP=(&(9i0 zzYbbe6Cwp%{~3#d9^Z6MvHQjd{NavGKEo!ixSs=2aDDb;{(4Dd1W!?PgeODyPJts3 zIWBgCvr`1Up{z0-R3l40aD@~B(#vUI!+xzNa(=2qSBRo@I=0hd%>RWM0=NfPzrm>u zG~zC_b&n$kG@%ycAoP&K>tzM3RkCpfUj%FSPUf4V1_q0c`VrpF!yey8?;KK%O5ciJ zyqQ$sXfSsSTo8=F3wR5{&N$FcW+}l^7DL6Q6-0B2L)ef;%-4XSCvRrh1Ofs^HWBb+HUSs# z^iuO*(2!Us{m%Kd`}aNlGnN(jbV6DAQGj>kk=gqF&R44bsJy*xJC!>3VEmX>Ngn_^cq3O>L=6q4Ilr%(M|(v2sx4~gosyIQF>pCpZq z_Vr9WEZ6+Ow!ITFUpk#z)~0s-l*38ZE%;D{NB9o#{`>rO!=+xvk7>h&KjSNu6vrRg zvk3U-oDLXfM>?%6YR2;4K5m8CEE6J3UIPP1K}W7*a?1B#SIR!I#4)%EomyGh8+Ulq zhj)|OsnZg=W4RoX6p;@W9(-0bf8FM3VN?u_-C#PkttX26DW8qT9{SR;rJQI%ksevw zH}9epfq>8VYPfWhR|N>tyI?g6BzO2S(rI%5#4E0R5A&a0$B*_;)6`Pdj$EW4Cq62>#d;Q;jc=t~P!zrmEi&@G11iuSuwQ(<9 zPzb*hwK{sctguh&8N+u^9i-YVTLbu)ShMdn#zSh4Ll!L0aaz#Qv4E_dSPt>%<&2)x zfwFl%ojLiE-(&k!;^okrr=;NHA&y4UoVPdStKCZG!tb4Em`!I2E1Q zw;C<~)cwk9)tsWShF^l!j8Cy^w#{s}dHP!Hg!lS$mO~5TPBrtJyGqIj7$q2ZSv@r8 zvv{zMI$0PYtO(i>Zk7Ejul+(uy~&FT;xq13!{BX5$x3!QMv3V6b4#bMH$^!D?`^Bl zcHyOW&rOkcJ?bsj*R4Hb4ZU;9<)m*BL%1Gp*f^;=D_Q!4j6z42&Fj`2o+><|L+mVR ztG*PomBeRxXSiXG$BmrjTE);RV<}5~=8PT>vj8w8?(z~NQmIkQ?Sn$uEU14zVg;k% zljVHtdyX80wM)LCMmKk;ai3HpDW2E9s@|lAbXbnQM;^>O+Sf#U-hp}efYYfU=kL=p z|J6GVhwXnTeh`$EymKEMv@Wilx5jz3o6aRzF~Bzu+Nn z!GdqoFNc%lQJqN2`#q=_puunnxFoz3qzccYkOg;X6h`U**B;G(m$I#TB^r)(86BG>1R?nxs1|0HRmji`eub-LeTu0S_~7#&w!B4tlkZ>@Ti zM!cuYs2sYQE=spc32qH^Hxyg!h)R|3SQ?*FMx+v)R`-2v4wb)!&pW0!K%h|<&-^4< zH$@WR0U{I^Kmq%ocnQ*Tk++&?r6%q~=s46`PTk3JEZ)s{>GEa1ndY*8q`|3aj2hL< z@`Gg^TphjGUT->BQ~-Sog|I+q8;wpgdeU)Io+7G;`Y0}N+u`tqFsxi8gAFemb#YYh zba^1TE=v6tI%7Z=@2peI1`WWA3TxM6eY{oG`p-mbklODBI>%`EPnnxidewxeof-!y?tkKh>qFjIhJKNPEE*%;dw`n|oNcZ}!d9i_IHw#B6MA&Y0p zBi}qzTOR5Ci`$kkah)KYY#ATkXw@ndYrs8HQFeD%B>w@k7dCS$+OsQ zkCM{JEuSPZPmO7^QWgflIfoSzP7-o{-7BBU8xFMQk3!Ec z8Cfq(w>ww*j^u|?uH?6q3hhrpIN~F(#U(hcWAdU`$GbGZ#{aQ5{>{`B7C!?=o(g<#6SD=V?p?-uRI!vx& zJ)Ss`nBD?1ZIuWn=V~(SSxA&WqU6?$7C%&Sp}&uPf!s;B&fF1&Y!!1JUs4Z-W*?H!zTDi7dE2hS^E9YNWy?Z3^9_ zqn)fN3&~mD-VtDS>K+f0z=nV>Kub9@(PIw9a-|w=0yRvzd{lb@K?S`&YvlkZUf!b9X)e^UG zSOJ;zlGS&@-@gxOc$i5z@p{6M!UEdapN|#Sc*wKHRx9>K zBJ53&hPP2iALaUrZ+LX=WxvOw^8>5p{Bl;lXO1^oGEOcI4WwwIGuHF6mPwG`uO~q} z62N6{h;F{n^`i=+d;?OR-`^P=G28vHa4Vd-2pan~X(@sCoY|mq7baAd_d#88g{2ot zwLHz1*3ovuDt6;=A8wER&@kuc1i)MMF`W^bT?ziGuKRoOOguwj_f(W|Lso;kL2gbt z1ytm48p7DN%kZsB2ag-%rXmO;G(4l=6|1<`AFO@Imfahu0A6%TUb;CgF?mYnwTJ+v zjWE@>$r;)i4Z3@A6os{pak=)_cKE=e*)0pLXv&6pFU&v>+m_s5yD&-V&!i-s<3> zBe}CNxH?b=Y|gKF=^ky=v>%vvq@vD?*UUr|PAAiCm|&kaY>W&pRKx7tj#)n(o`bgd zPZ|9NFIfD>9=`p=^${|bCjlh{94d;1Bu6w{tlAoJ-}29$%?m#wd|YmNk-$HD&{J|f zWJ|GG>R0g~7Qy>f|3#OaYpEEXBBfw7Y_A-9LwCcXEA-bTUU0|CEK}07;INxb8+sY0 z^1BJ&@~~Jsl{EepD<0dOdy*)9B;djs;-gxjC2AT^S9=oGv83>})(v`!nahc7yr~mo|b^`vpt>K==+8uB{(Tc&{ z?Fc=(rmj@v42tgh|N#F8U{nhOBNF z#X^!U?0hqeWv^DEk0Qx*fc>-70S6h3>={^p05qyuXyRrUtJBds_Y@)=H58JI3E9L) zd$^YPxtj8=l>_AQ-D4ZeDH{JIK$6p*@qhz~I!}rnvhP5doFV|O5vi$>cKGA?n)**Z z^}12E`PC;S(@_OBxy@F`n41iZDdUWI+s1p~Y(SlsRJ)Q10{}gQ#sg1ksYzi%@p(e~ zEd*&9=mO?T7wHL5WZ+4QP90e&nZp%glEuNrB+`^j(nG@@;pe1C_E>qUM1|)5< zx-|Vh4Dr01pNA1#GI=T5H7h}DO(&cIK$!rbKO*+Pzsdr%h7_Cn3(uGHdUh~<&;(KF z#HCSKQ{*YwfFW1~@t>@Cyr^s$;n^8Xe0YfLlT=KLZ}8~e38n)>=GJ1ALrp8@lK9or z0d;9C2!CtFZ=p|TvgoyI*bmGfEWHuhKWvL=_FShU-jZfzI)xiQsj@eEZ8fE6O{&*Z zUH@%57eaf`jquW+o7#^gSg@Ym_B;hUXj@q1A&a*F%aPLO*xlkYH$qMdlMNCDdZ?*D zDZ)-04b3Ui^=p7W&)3L5PL%CEyk9k(XcaEuei&!)P5DhOVv+ZXRyX36S;4Cb;4^Lj zWNzfV?LvEHn9E+!PQ7%LMu>8k1oIij1FoPgH=nahmibfQdL!yrW8Tl9c58>hiyq)JKnETx z^vplK)Z9dU(6n=}=vjNc`X))dKN6&&eBh@)5~+F}mgLULbqd}LMiT8!OW(1HQGIMw z+)eY!LcdjeOp#mCoBF2!fCt#11lRAsR50-&AC@3$;RPej-lvSPvJtZ_3QI1Jz?qt7 zxIxc|%;yK0g7Hmxz@qIs@2$~?yF<|zyF`}Fnitiw#9_}~aGc>hJ=O|^<5l+8lvCco zF#D8--8+M?HxA|ep)gc{59!z9C;vi%9aa${#+q=h&JPngm%!wsZL=`?1GABa?Dv9j|tJy?2qOm4R_RWjwxPc#N4D`dlAB%15DKG8Vwznct^8O1Lw^Q7}C-5%wQZ=tP%w+Bo zj;tsBlTX*v92OiNY!#T=2}!ATYlY2~q53Fba~kK!PltdvIy>($l1H${LyU1x8D`_h zz-884?T=7NAwglTP}JhYrjcI1?M4jR%mq&iDB!S9;DPg^fD7n|Vh~Kvw(8WqZRqeH zSa4)1i={@Is|=34i<&9b6W5u`BP7_fw-3Px_td6&rAZttS!0}adTq+1Xy(AS4Bek9 zEv5cQm=O*yxT;^}5m1@k;L|Z0c3?mHZQ6GR{nO&dViYkvVRr03=Qcgq zkZ}1YQ1n(Sl_9I=uN5)n}V0^s$PCmrxW?O-%HCBgDE`G%4qxz;_4DUBwwiQU7y8kY2Dipkm0zUZ<%mMB6?j23g z0#cYMBrN{{ui&F*T&@^CKk)wwbN!FuU62z~SKoTe2yrdiThBK0H7rfmb~wI(R{@}L zGmRbgr`VRng!5W5RQZK?&Ry_;aT~g|#4_O3UH{fyBkR1@-Fa ze5;JF?i|=d$kI=FFe~F8amqN8#LAU(GBV#!D)UlQ*CYRUCfq9u%iCX?=DH3ge8Rt# zn@=o?JiV!&pHUPUE0Fb8Ra`xz4;sc1KU`Rc{5iO{YOyJAX!%?iW@H<2%EbE&=7E8m zI=$$ihbrp!MCpuHcC54P-$d{VD8E-g7c_9l;T8_&ukO>fTIZ z;O>&Uw-oVIyUh0_s|25u+w-mod`p)stdp5<@vDfd!@$yfvkTUN_Ne~h4e%czoMLKw zL=S?2-Xnp5%zB@)Y68qa25Zq^vkdWad^+6x9atX%BNpv3GPdy&%V7nYxkDl5j>af0 z#q37;oyW-Esx8P_SYnb#e?@jfwoMiAp?kVfSfel18^S~*{E_i#(zavq=zU{a3I=Y1i`&-E0sjK=wimslM@$Z;_T7%z%up#5XFajp>I_BpaSb}N& zl;E)RDJp-Ee*fDwvs)h2f4ej`Uucbu@i8ywVZydN&PAN^pKk)vQ*~l>QEfF}M(^lv z;T84!tHOMtiCPV0aAS>~vST9VI2n8Fb|C8hrFt1g-}nEt_uWrTZBg4O3Koir0@9*a z1qA_7iqwc)q(}fkdKE;Z_f7(cfC_?A6@-9Nq=w#;fQS)LLQkln8bW~3LI@%79liIR zZ@!uLANXdjKQQCT+3T#m_D;^;Ydz1iu?H~@cfWTW>|qa7u1c~z-3x;@?rH7qoq6@L zvS% z*yLc)w?sU7_fOoNkVa!ZsuKfWWGw_WdEynlp!!x5ehl+`EG3Wsy2s=6x159!Vs%)- zo=66Uq4kq+t3F<txJuCb{C3D8n&OI6z_+AUJw07*}90t(Yjz~(HMVv zQx8=&ow((!BRI#Dfio8r6O6MCnz}ZGq?8T8=&c_}#Vx4igRm>tv1G3>k($?(?)ZhO zLW~sIqv!Uf^8N+J6yLct($(RjmR1P|nb{ozFWeLnUK9CThG@`a)|pwAe)KCY zOA7iPgvF02S=w0O>Zbf!9Vbg{WJ_m6R7@4qn9D)uYSHY)p1Y?4K@rV7Z^YPv0w4KR z?yr`!J1v|>Of^11S_bmiSzg?M_`73w3<};a!|&2_58U(z2!fJHJmmPEOPqn+>0nB0 zh5du2GTDOOowYBNhB~eY+j{r_GQf<;A~3AlL%OAh9TA`2YU;hC3DKWr@w1`PYt-Kr z62#0%YILDjxs#3^z0@rWRbH&z6eb9xf|K>m_~ao-SeG$Z(xQ#PqM|JVgNd@C27NkQNo!MrNMUMuH)b4I+b>| z+k$#(dub-2ab;))Q0SS2(sG+O&z`a(8V`o-8eZkCL-SKog=giL`SxrgxXZ?>#I$Zx z#$ORw?UaTvxeToDvcbFn1;1qtYKaIPA923%+HUXshI?0INs3z2;3%-E+xqz@_5L$9 z>ZNUJ+aOK4JFn#k?9uQ}Vs`Fr!`9->N^R=Q&oQx))6(S|NL^NL6T!mKR*Y1Gxh;{i zDFIPls7S$kv@FfPR%u6`A}3Q+1s|(GST6BebBq#1X@nWg-e%=+X+(r)N8&jRM}~G$vD)O88Z5j( z1X?W2K$h4)P+Fy}h31m$HtpQ|(L1-0LAg~}pJlD*I5AH|PMl8HrZ>8C(@^Wc|CGSZ zi%^W)nVvw z^&72FebEK)0538#y4GnT>XS?RIE(P5K6D1*t_q>fT)@bw97k}kg>%U1yN)?g>YddQ z!FJ3u@|2%jwZ5X0cH^)HD2=Sd38wH^xWBE0cOL2t`Hn4L`j9j36fxIcbteILdy0o3-y49(d!tGP3&5K2Ip8&0@B$g4RW%{UNeY?eQ9WrT}Tlk zXA|Er>S{pUg7$X&QXQS8bYA#|3VT15R*OlZqQqO`eDmQI4tC!)k78Pt0$VOj8eoV8?~%jQ7H(FWYX>`?JyhEzunD-A-v)Y%+v9O-bnUYow_UcG3dBdnyolBu9LE@y>e zU;UCEHds_Y7E+R>P=`Sat%+RlY7uv~bIx2%D;!E=pn_~q$X3*UXd0gh;V&f4Y{l%8 z5L)~P<}cN;2oIfz526X~C7+%=ihtOP-S!qi_o2cNy7qiPHQQP9hU_YLc#go|(Oj?| zO#tjg%SNRiMjMWJ@7LyC+JcSi|1I!0%3Orv=lTN?6gztXNX#A}y2}px*p>HOVvo(N z*7dB-vyIqCy}AWA5WL-J9tbWc1Z7;iy62*yrCgC8hX2*FGA@&-o>@)IFHGEaG}~6t z4ZzG=#6`#fKN?uvuKiC}@1?)CG|H5{59pH8W%VPmx99Mwp8!ssk}J7qIBMQL2a+K=t(OqTAad=-D$K% z>-p7Qw0sEWo^7FiCoBXcf%S~y=w>~%44b0@2J#6(U#sG~q$vAa zDtd!{svKDuz9hNh`(UccYS-m+HYpG*kcwoRWzGSU3paD~H5hvB{G}I;8aTx<+*aJ# zzB}6O{rDT`GlUV?n8b9ZG@EgLCoDC8`hadcMAGfE=(ED zp(&?+SuI4coAvBm;e?pa8*xq+)OcT9qtX_0r#m-7Xd>KivAb-G<{09o)VosQ!aaCr z0m?~(jxyHVxJxIJw94!f$MoIu26I+}2-?L`5@B)H%RGkKl(KkEsxwej*EB6^Py}KH zt#%!OipNe!=)iG#bLH_FAY3i=CdU%!eE!HT8| zeUfrXrc%Owyc7gw8X{JF;H@4V&}30lCv=hVByGU;RZjxp(Ydeq+K&r^BGDc}R#Wj! zQ&~<7g4Fi;;N?3^U)%NhR#4~@k5W7FCzZ{k-AnT>u`El47uGNu+huyRpM_m-)>XaV z`Z?L3c!nmdaS&Wnj5lv%?vuvn@*9KR!{fS6dh|cgO!=#O>^c!Lm84v!a8GdQv(TJD ze05;4*fT&wi`5MhThqBwli3A!8y%I-=?(MU-Y13mzipFt|K#o+ka_8+vFmd?&qWqa z@#P(k#C}ppJq_zuHL;|P*z@TKxNc<d#AgIPlyUAby5t*kx?t{sp zO9tv7W@P$aNmL)0&^#K`wl9pEC&N!#aEe)|omglPOhIA++yC`p}eKIW) zj|Ue^d-vC( zx+3RfuWvt_(@H$UT7qrR#}`G$S{cb}mXCd`4ROZjG_T8Di7{!Y#Y6a||Bj!0Wy=%^aFZQ>v=zW@Cq~ z%<@5~W6&-?D=HGHc6-Cz7@5w<9vo7d+3)j%NIdQtLn>gJ14+bGX4!foC6g2zWW5>` zJX(L88!M_$Z6_uNIS(_7s@p~&i%ont4<{7#l@EjO!zi>L;7{(>zx;7&VZ}M+mu;wA zv~?){15oSv(VtH&y1qauD@j8u50Q(S2z7|EP+^6&GufV_BE6H&54o#=1cZIO=w1Yn z8{^4Lx#!Lcq}=$jCx}5RU<;VT`;&WLW_ZsHtB=d>`6+Caepon9&PdW-C+z{#w<@JvsO`*Ar)D?c_F2RgWOsG6w6iXYfQ5-a( z_=2RlLb&g+&2ciL<1c}^Q#d^aN>9K{g~Fr+BT%l>`IHpSfJgf529^0}VC+_aG!h2- zeQ5#|ygu~(C8@aO-6|c^hR{Dc{}vftd67A<^9wE`dET4fAyf0Y`{?72xFqC|LiQ89Af>v$c@f6dJ zsqT7+c2b+%lq96sR2cAG!O~n}B&gj>8B>UT$q-M_{jml3SxAxaP`wV7{CPe}O5#|F zdl=|o3;LKi^Fo)PAP2cEKKa+Dj|qZVM(Q*v&ae`Oh(c}FjOO9Cw$**$&GRm5+JQSA zfFHXMySIpK2#fwV4>a3jvZw|}!NiLE4r9%w@4-LX$Gi;O1Rm2HH5J`;#gw%P1@WzX zi?0T1bI%RAb*ycEed!W!MwHY=&E-bZv__Tf(?z+3CS$wI`=7>jq#kv^`qiBm*yciv zKiIa`$9XbE%!dNt^1~;uX=n+1BI^6U|eC~4@NTh70-w1Bp}$byXCu3a3)s-b$LE2}xXdD5hJPZ?n)gaTf5`XNC9-z$RY#1cs$iSRcl5Jz zol^6AZU@ZDM`@3@7N+`bw2E?$oXt5|_xhWUjm9oSLC3E3ZXv-@;oH zZ+x55RhI``-;~qHdfBdZ*+hNlV%HxClHuh4?wJ$rHk z!SW4BvSl|QyC@1}Fuj#sKl5Rr{|G&u(&Sjb;20KKwb1m-yJm(cw;*gZg5G(G4$HjD z!v9@@%}vZXr3m>f-v4LBNzIbQoe&UA&%|ea8N2`XFv`b^K<;IO0u|5Ny<>ZK1G-I5 zbhbbDlJt0<5wPZ4DAeL=SXAwPW3TQIyc!Iu+Y^xz+_4On8EX^&kYdItq6Ag3jMsS#vI6nUhY{Mj%#l4(#F!57b%DEb5P^J2vzyVho_n8 z9&OfGyHQuhU~K*~sjPWEOg8HH;lU$^+wM?bsYcHc)s6<4!AUh>GAxb`EP_0~XNe2$ zR7F=_V{LbFea>yQ4Rv7ms^1sv1F2R~4oZsi9Ba`Jl_FO4C(8T?XY_8#0wFs;^&A9N(bq4@?su)i2Od^ROPJ z={I4}!LTZ?k)G+xj=Ag6HwmRvaQ9#~wE%zcX1$7_0ak12l8wUj9!zms*r=|$OAp;# z@VU<=8XiYzP$5`T-s~WI28Qj1RZ*LF#~9`!1g8_Zo&bQzgl8v4$-1@gt~hoD=k*BXP>sGRLY#j-8i%dZnYH+w}2$iBWZz8}>jB9vCG?1kTsP z2$_E0qK%XnMccZaYGD7~Bjkm8G@1yq%Ar)y_UMNeRF8qV>($=f*m`WK*>bH;a)i7R z=7ch4Sc@+TNK%H$oK)9VE}1tQ&KyT@_(Um26C~%Dthd>Nya?b8R@~811&?!L1~knO zr{wLei8oW7&0jd3!Q`MC-1lB`0dy*s&)OV&ysYn)j(R`#sSFuyOpMmcVoQxB@hG1x zJy&0KOU`Zff~|E^y$Y7P`#4oeAa2Jc&H5@xRyCH&f{{{>=D(^YN%!RQX!bH*du+a; z1+}n{{ocC!cw|V`&X|ZUgDpV>`X3rdk1W-_o1ETgyLt8GhTgcPzToz)M3nrpih)K5 z%Yi5G`^veeaD}B6-PeDBFb&9u@DV+#XZ_u{pM?Wp2g zf>pE45S+tGvr6GMJ%j3etv+f%{80&C!>?VD?uJhS`OSOc$WLa_{J2YX#cWs5xYWRY zhn~$CYk_eoUCQXdwteTd5@{o*iK&;(h`9)N6*rH35;(gMYz2x4eX6bN^&295p#k6n z34k2Tiy(p0#&|s2*>* zZ8`B~J2)d({d%;48#?3C8~-0S`7^@fo(_CU(~N%e=;&gm1uG(8FUL-Il^%?k+A1qS z9S3uzer*mt+il$HVU|H9)t<^7B^$nz0T)|7_Psz?zi8Q_Oiq1{vU6@7sx|)q0 zQ-n3~7VC^w6Pt|prs(i^5u&!|9yTY+#D<^pzT_}pbn<4l+~ z;te)SFWsr=xdlo@c@0V%Snv-8f*q_Cj&tXQaZP}dVLlT>&Q&q_&phvB*YCALXlqi`Z!V#{vvseo zK^(!P`*7o|F+z$+X~EC+yW$M#0Gvkg%DU*Xe3dJ7kA#)o_qW>I;Q`bpIJ~b$**&f0 zH?)9Ff!$tE1|OV%?oc_`Q-iG1Pux@O9=HWxy|wtp^g_h}(sIDg%lttNwkh>!h??vQ zEJ0$ys83-^gJ2gN^py;KbOVhv@^*j6ra5bS%DxQ}E zN3!C*cXo`I*~4{sJStW|KTtp&1dj_AaYlbTX9djrpBaZ=Rb7O=eRdKZ>%hyXz zI^Cl-^_)W?JoUO3f)N50!{se6sB5l!*G5Wtf>>Gr&?bI3SWR1+@b*jONFcvM+%!1* znJoS^82x-D`b*VC5$DJHgB)1L0RJa$LpsB$*Yz>j_Jx6P1wW3)Q(=lMc<@p1)Gjrf+M)V`r+{sqgu)%+#q?OhKkS{ z2}*a?kf;rUeNKEJs5*BDP=H(kpC1^xcA4PdOm{kqZP+44mE5_59cHk@b+({kH1!8R3LN_DHj9 z>R`dYmtydAbj{t3BBiHD+jx(PayA9|BVU)SnPF!(p1gQTbx1M(LMYmGGwlrfc=6Ce zbtGoC?WR?PU1$9_`BEj=_!`FpPXSnOcs2DE*?FZMQ~{?2q;Jz4e-!e(N~K}53*7>X zQ(e*b;veEv;kT8|$(KA%9K(=zJTMp5%H~4^pR<)!|5QAEjLTbK<4M$0qw%Mpb>R)@ zigk|Yk~H{r_Vu(!d)JN~u8|C5VQqc#d#h&}Mtgns*p`NSD#oG!yUm?gdRCs(Hi0%8 zAB5e^O!qHSTlIbcH|>g<9MJ#u5XkFsH$9mlK8zJ(EQ^I+_PHU+^@@2(|H6%T6D|f} zN|@%$6%#;7lr)NzF2?l+;mIXsQlz`*!TCxbUrI#4jfSPc2iio19`7I3jvN9T&>psZ z?&2D-R|x3f!cp51f(!Q)NP0v%K6%%^QR9Y~n^g?F!XSKAETF!8l==SE$~*q>7asT_ zltrsy5NxapSqouW#kI!k7w{->oX1t=g2X!HR5*qjt#<_n$Gg)?5QzD9e{ZO|F1%v=g7!sP8)_oPQ6mZ!80Su`B&rDq;?6fC#5<8`^;Wj(0+R-`jsd2x{C8 zL&sjqSD5;OxPqoJF0QAdMRe^g22L>ZOZdfvqcuWg^mWkY_@lH*SjvHkN zE^WN~wpsqL?=>2>t}09$M{$7>^+|!F6~B^_Lsv6m!-wq@S{ri3ov=SDFA`1VMT~k{ zWD1^1S;@7G8KVNA&$gyo^clqwqu!HXx%}699YA)cjA!Slyu#PHNDsX$TjlwKw^STT zy78yYLG~{LMz+$>X7#TY3JYhi-#;Hzy?}ZzeXXc~ z!e_;}xFEW7az=Mp_xd{F82x?t4Dpb5B=PlHyf7PEiBg=4jSOorct zx7H$i?jjuGtDiD~pH_X^wyt3`B1PSQifYu%+fi=TF4Y<|wl zFcATm$CTIS8pO^0&(-9>%nCmbqwRzgNo)`wt);beKpBR-UTCW2S?*r2^%PUz?Rd%? zQIhjAz}~{!CbK8d27@1wBwKy(q|<3rex5t~J;beY7qQUE0XaeXhG{xXVi+sb5`FStDVK|Rjh|A%Tw;3Rm>wZ~ED z)8OsTc{LkVhZ=f+#_I2_aD`k2nAV%e44qCh z@rSE)PHud;@U^I(`|d;YC-G~WCEc5RrjfAg#kfM<>T0r|aCh`AyPIszx@ImT=k`V` zyTgqIFPK?580R_;<-g_&L|7Jct1)m#Te3X1`6^d;~H2~M#_XLeIA z`}=(vo&65RUQP^O`8b%#sB_}!$r=#378dtheCncH0$4{8YYU{KV1igg2e}v>8FjUf=BfaDY_dcghbrW%Jy@E9V2e+P^0NA@XLnG9pp_SpRzHN|DYbmw>5I zV>aAaSfW-G)!X*G1@%eJ&uuq5eAi*adXlr67kMAvsBVfk@kf_fG1) zNmq!9<;8>MN-vNY*n4T%-44^Y;VsdrDU%CM;$>GUr6$_D^_4`ErO|NU1v|V z>Ca4Ab@A6n_2g@>_>EjlvYH|qN&vmVs)-+7OdRU7Q~2zi``JCfN7yAqEhC)RUz2Dl z^tY{aG(&)0GV$gM<`*?Zh=r;VRa`04SOHlJ8a6!cVS-@3`KFdOzRMDiR7?sou#5Yd zV(#1OH!J`=nN%*7RH0(a-Cr{LAscaVFyB0)Fmq-I3Gu$DOy<=%4 zTfns>!(P?1ZR-AY9&TpZ|5~*>yGG_*tqDPpT!-OX@-4lXfffr4RNKo!s7im8IUeb`bgPf<8bSD_|0ws!2 zEQb*@oCAP0yz7U=iGF}i_VIN=?PNo+`1*zxl+Y*Xw>SB+y@Ew}ArfVLW69BPg2I91 zONHxrarH)n^bG`n8QTRmrOicUJj&gwx8ylhn6-QlElEMky^v*Z2o9#ql~~S}6i3cZ z6;O8h1ia?0AB}$XwE*QRss;-5^Yuyx=J$wlTI=}WCAEZ}FB*lEHH^_+%tbbo6y?oTfc`j_Q&4-j@B9}unh%V8(+ThsRJsEd4j#&(!MVc!e zd+SIU;%XjUTTZMmV=dm#nW{B#u3&~bGR=AMNvv+8X02c6B*6rQ3CFC%Q%!ongitwZ zoUe>1^r*Cr1u1Y;S~ACf1P*BTFE8^mN;3*x*a+QtIr}Sy)cc!{wy49JDtflC64B?Fx&vp+@)%oH0_B~#1xIfuD8yaw0JDfqAYj1Xg`alvhv z(l^_$PSFCN*aCDF3Z97+KD6?`zrc79plM0EArTS?P9JhJzG6yNZb;U-jy3JgK#I*W zR>CUI@w%M(27k0Ul}0HExAU;1qVj~$%`4TZwA%dE`|Rdv<}0|RyE>0$M*G+lZ+oX6 zF3PGFn-B_S5@I%3^i_ypKgh@XloU!{_)$NVB$_a1z228!9=i9~Q;CHzzcoG3{6-9M z#;W2h{aO~D#<%;EVwnmpT4I!!B?>+dpEIzNSJ6@sVXyx^jPKid7IXQGm|wqb+D3P} z7|tnio=BBLNekY%V92|u;$kS4=RUkLd3rStFJn`daX-;wNz}Ps%#{><;6%ZzUuDCg01C6dOXnO5wXnTzhnXn1Xcs7rh(Krx zk3+T^VejRAygEciZei3L|)M$vgs47$9IEf;JLj!7%90QLNfe0B* z3^E4*{6freTvH-=e2AB;^Ku##(Yd53-olkJCgx5Clrk2p@Go1(`o6P|P0BRuvGS8& zuk5s94n4ykf5BQVy&9Lrb_RAIGb(%GrpK41nqaP;P3G2@5Rprh8->2tTX-?nuZ7q{ zaDZ}NRX|J%2MJ{nm>cr=BAJ;IOcm@_x}REBQ3bCPzaG!2$(WMHPOT6T z6IKT8L`TS)E{!pFlCplvmE)O7TH417CaWVudJ3KwUPsWqB=*jK#d5h%UjVfcUTGsZ z|5vJkQoQ3OnRB7;apBMc5#fAiVn=Lfh)Em@>pxXWYb&(ZZdHga{s{nV=-h(@`bx~E z9W<^a+6I(P+;scpXuIm$OJe%*bPvu}OjQj!Q}CGNo2t-20}k7(*!u>v?bwdFn!pF6 z%FEZN+ZzW>y4Vg%R>gRYn?m?Pi0x~!THl+0hocMXmt0~n2hLsZFkLln>xwpUajfH? z2c=dt^{K$ZV?XnQYV7*nUjctbt_CDXrE@Nx>URDFFT@sY9sJN0SQ_`|@)VFa-&S;= z!WQN??Y&i@yOX*tz0|D!y%sc>xf5<6xNYMl z@4xJrEqm(|qbPL6n3XICt93bZ-&%2NyN_JUdt~Ztr4@1ELD5 zE9!1oV`oyAT9l|T*&}mJ36>{G-`fLqBuQLhf5bS-Qgz_%^E~jI6tT8EMYTJ~n1vh? zu}Z=kGpkNu)?rMQ|x50)_$ zXy?9e#^y%Z_F&sHrO=v1CtGC|m)hswEYnt&W5RTp13$=4!^Sz)36v3t%VW5*BuNY@ zs{aORWs-Y!7)z-S=l)QZ&9LvG-Qn;@QmiU(QnRJ&7nQjK7Zng=zR%FZ)-|_9)&n&_ zH!+*15Ej-Crgq7+FZEZN;CUY3-r>l@gOU{;VeNLm-#s@JTMRfw87I^glr?>F7a^|8 zpfMMV0D&oa9~14RA-((ii>M0qxm2U2+CxNR`Oo`#Q4Pz5>KWtEZKqHD0fX05x$kle z<`_IbIMUNI_OzyA6@!~~lbiG|uhw^XSuCCbS13mPph~^Oyp%vr4bfo+w5>vh<(+B) zC+gZK|Gn2J+moGdicl}3#wtT1CT%*MfBlpZ{r)Yqd6w1&O`^mH3lm~hQUV;`e`}kG znf6-1`hGZj8r(E?BdHh#9W3zgkk)JLrQdR3zIEFp(F1Ng`DqKJ`Cf=RS3TNQOCWX_ z<}chfx!PB(|G@n=rv1gcG4aA(tCfRlkLD7*&JOb9J=$M>Vwc&J*xk@Ui@EC^5(muW z!DLF1S%>N=TK#VE2gk_JxC;Zz$B0eZVKva}^tqkHZYZ}}dN{)I=6BlV4P@Rwn55w__E}X+#x~2PYp}|UiX(np3 zYH7f+{;gR6$(0*t*AXZIwccUBmJwaVEnbxi8J6X*36Z zQ%m%NB!GP$0+pw%1mb#(kTdW74!j!h-{1eXl2s!Q$^U~}&%?JoD!lXOa%3SO2NDbc z>ioXjQeRC9FryMj{(N9B4Ky5obuC8B*+*{e=f>ve|7ifI>EMUwmzB=CuYyu6~-budK(kSU8k{09lnk_a3Cm@5u|&SfOG7XGhe zCBWSeJ$&Vl15$D?zx?Au2|(5G<^XQ>x#DeoTE+Mu!*|XBoBBi--|fo1ugIu5k@k~E z|B&Wb=7HM{0df{|gJdkyad zLjNbAP{0k@&UyEA0IJ?@|L@^aa$$A5a~zjyuYu|FMI`yF6;KqoG-G)g*}`48zVk`pj62B@3-?s4(IJstrbR0{#3 z)ww~KV*8&SV1XXg0)uvOg?mp8|L@s_z`=klD+++(P^as3mVJc%pC>KK0Z)mvKV;bH<}RDGJi8CN`JtQh;se~)AQBb8zUu&bmZ zpgaeVM}O?zp#hHh+W?L^0TO$+{)F4Z z{i8{>>-zr=!tZQzpEJ;$?>7|#5c>Snz8uh_xx;Y;{M*?Z0Jr?l#oTNlSUWxQfDY+! zo5H{Ic1(+fKN57#B@gj?ej)!EfyiwjpPxJNo3YSpw{NA#$;|!N1RXFD3}}bc?|rT9 z%jm&=`nSO^U`pF7r_|^GVKcDwu0P55$B{_2UEqsv?>tAhr;}GUZn@9XA-%1HKkh7E zk%&Bm2g2b!A$WWBbn8?6=qTbJX3NxXYY{0m41h%*gF60m6cqgHpTX@j1BOA!(>D;{ zi2(LI+D5|z)&DUk5qA0T8yrAj+YddrGzz+1p$((__2;8MjUGCxfB<4l?cN6=lGwMi zLtgN0u|IT{$Q+>p}0`WI( zwjI>r88vP3f^ov2_sNOVf2_iQZ{8i|x6MX{HGtmkc6C65JBLZSezrI#!Vb3DG4Gz& z_UFF;_0P`1a{HfOm&gkdWKPk~|LaZvUHmTs|3%=x2>cg;|03{T1pbS_e-ZfqD+2t- aZ?F`V=Vk^|+3~+U*U{AfyW+0>v;PD8lIQ~f diff --git a/README.md b/README.md index f45a4d8..0e4c26b 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,31 @@ pytest The backend defaults to SQLite for the prototype and can be switched with the `DATABASE_URL` environment variable. +### Backend logging + +The backend now uses a shared console logger with a styled startup banner, concise request logs, and clean shutdown summaries. + +Useful logging controls: + +```bash +APP_ENV=production +LOG_LEVEL=INFO +LOG_VERBOSE=1 +NO_COLOR=1 +``` + +- `LOG_LEVEL` sets the base Python log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). +- `LOG_VERBOSE=1` enables extra startup and route detail without changing normal request noise. +- `NO_COLOR=1` disables colours automatically for plain terminals, Docker log collection, or CI output. +- Colours are also disabled automatically when output is not a TTY. + +Typical local development run: + +```bash +cd backend +LOG_VERBOSE=1 uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + ## Frontend Install dependencies and start the dev server: diff --git a/backend/Dockerfile b/backend/Dockerfile index 0b37be4..4fdf808 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,11 +5,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app + COPY backend /app RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir . + pip install --no-cache-dir . && \ + chown -R app:app /app + +USER app EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')" + CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/access.py b/backend/app/api/access.py index 7fa9726..e113419 100644 --- a/backend/app/api/access.py +++ b/backend/app/api/access.py @@ -7,7 +7,7 @@ the current user has, then use those keys to hide/show navigation items. """ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -21,12 +21,19 @@ from app.core.access import ( require_permission, ) from app.core.config import settings +from app.core.http import CLIENT_AUTH_COOKIE +from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key +from app.core.security_logging import log_security_event from app.core.security import hash_password, issue_token, verify_password from app.db.session import get_db from app.models.access import Permission, Role, User router = APIRouter(prefix="/api/access", tags=["access"]) +login_rate_limiter = SlidingWindowRateLimiter( + limit=settings.login_rate_limit_attempts, + window_seconds=settings.login_rate_limit_window_seconds, +) class LoginRequest(BaseModel): @@ -75,7 +82,10 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio role_name = user.role.name if user.role else None token = None if include_token: - token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email}) + token = issue_token( + {"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email}, + ttl_seconds=settings.session_ttl_seconds, + ) # role="internal" is a marker the shared auth deps recognise so internal # users can hit the same routes as client-portal users without being # confused with them. Display name lives in role_name / client_role. @@ -96,14 +106,16 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio @router.post("/login", response_model=UserSession) -def login(payload: LoginRequest, db: Session = Depends(get_db)): +def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): """Internal-user login. Authenticates against a shared internal password (``ADMIN_PASSWORD``) and looks up the user by email. Inactive or unknown users are rejected with a generic 401 to avoid leaking which emails are valid. """ + login_rate_limiter.hit(request_client_key(request, suffix="internal-login")) if payload.password != settings.admin_password: + log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") email = payload.email.strip().lower() @@ -113,15 +125,20 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)): .options(selectinload(User.role).selectinload(Role.permissions)) ) if user is None or not user.is_active: + log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") - return _serialize_session(user, include_token=True) + session = _serialize_session(user, include_token=True) + if session.token: + CLIENT_AUTH_COOKIE.apply(response, session.token) + log_security_event("auth.login_succeeded", audience="internal", role=user.role.name if user.role else None, user_id=user.id) + return session.model_copy(update={"token": None}) @router.get("/me", response_model=UserSession) def read_me(user: User = Depends(get_current_user)): """Return the current user with permission keys for UI navigation gating.""" - return _serialize_session(user) + return _serialize_session(user).model_copy(update={"token": None}) @router.get("/me/permissions", response_model=list[str]) @@ -181,7 +198,14 @@ def update_me( db.commit() db.refresh(user) - return _serialize_session(user, include_token=True) + return _serialize_session(user, include_token=True).model_copy(update={"token": None}) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout(response: Response): + CLIENT_AUTH_COOKIE.clear(response) + response.status_code = status.HTTP_204_NO_CONTENT + return None # Permission-enforced administrative endpoints. Route bodies should not check diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 906d202..d87cb7c 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,16 +1,23 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import AuthSession, require_admin_session, require_client_session from app.core.config import settings +from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE +from app.core.rate_limit import SlidingWindowRateLimiter, request_client_key +from app.core.security_logging import log_security_event from app.core.security import issue_token from app.db.session import get_db from app.models.client_access import ClientAccount from app.services.client_access_service import get_client_user_by_email, module_access_map router = APIRouter(prefix="/api/auth", tags=["auth"]) +login_rate_limiter = SlidingWindowRateLimiter( + limit=settings.login_rate_limit_attempts, + window_seconds=settings.login_rate_limit_window_seconds, +) class LoginRequest(BaseModel): @@ -27,7 +34,7 @@ class SessionResponse(BaseModel): user_id: int | None = None client_account_id: int | None = None module_permissions: dict[str, str] = Field(default_factory=dict) - token: str + token: str | None = None def _build_session_response( @@ -50,7 +57,8 @@ def _build_session_response( "client_role": client_role, "user_id": user_id, "client_account_id": client_account_id, - } + }, + ttl_seconds=settings.session_ttl_seconds, ) return SessionResponse( name=name, @@ -66,19 +74,22 @@ def _build_session_response( @router.post("/client/login", response_model=SessionResponse) -def client_login(payload: LoginRequest, db: Session = Depends(get_db)): +def client_login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)): + login_rate_limiter.hit(request_client_key(request, suffix="client-login")) if payload.password != settings.client_password: + log_security_event("auth.login_failed", audience="client", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") user = get_client_user_by_email(db, email=payload.email.strip().lower()) if user is None: + log_security_event("auth.login_failed", audience="client", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password") client_account = db.scalar(select(ClientAccount).where(ClientAccount.id == user.client_account_id)) if client_account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured for this user") - return _build_session_response( + session_response = _build_session_response( name=user.full_name, email=user.email, role="client", @@ -88,14 +99,24 @@ def client_login(payload: LoginRequest, db: Session = Depends(get_db)): client_account_id=client_account.id, module_permissions=module_access_map(user), ) + if session_response.token: + CLIENT_AUTH_COOKIE.apply(response, session_response.token) + log_security_event("auth.login_succeeded", audience="client", role="client", user_id=user.id, tenant_id=client_account.tenant_id) + return session_response.model_copy(update={"token": None}) @router.post("/admin/login", response_model=SessionResponse) -def admin_login(payload: LoginRequest): +def admin_login(payload: LoginRequest, response: Response, request: Request): + login_rate_limiter.hit(request_client_key(request, suffix="admin-login")) if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password: + log_security_event("auth.login_failed", audience="admin", ip=request_client_key(request)) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password") - return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin") + session_response = _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin") + if session_response.token: + ADMIN_AUTH_COOKIE.apply(response, session_response.token) + log_security_event("auth.login_succeeded", audience="admin", role="admin") + return session_response.model_copy(update={"token": None}) @router.get("/client/session", response_model=SessionResponse) @@ -112,9 +133,23 @@ def read_client_session(session: AuthSession = Depends(require_client_session), user_id=user.id, client_account_id=user.client_account_id, module_permissions=module_access_map(user), - ) + ).model_copy(update={"token": None}) @router.get("/admin/session", response_model=SessionResponse) def read_admin_session(session: AuthSession = Depends(require_admin_session)): - return _build_session_response(name=session.name, email=session.email, role=session.role) + return _build_session_response(name=session.name, email=session.email, role=session.role).model_copy(update={"token": None}) + + +@router.post("/client/logout", status_code=status.HTTP_204_NO_CONTENT) +def client_logout(response: Response): + CLIENT_AUTH_COOKIE.clear(response) + response.status_code = status.HTTP_204_NO_CONTENT + return None + + +@router.post("/admin/logout", status_code=status.HTTP_204_NO_CONTENT) +def admin_logout(response: Response): + ADMIN_AUTH_COOKIE.clear(response) + response.status_code = status.HTTP_204_NO_CONTENT + return None diff --git a/backend/app/api/client_access.py b/backend/app/api/client_access.py index 17784e1..c1cea7c 100644 --- a/backend/app/api/client_access.py +++ b/backend/app/api/client_access.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, selectinload @@ -78,10 +78,11 @@ def _actor_metadata(session: AuthSession) -> dict[str, str]: @router.get("", response_model=list[ClientAccessRead]) def get_client_access( + limit: int = Query(default=100, ge=1, le=200), db: Session = Depends(get_db), session: AuthSession = Depends(require_client_access_manager_session), ): - return [serialize_client_account(client) for client in _authorized_client_scope(db, session)] + return [serialize_client_account(client) for client in _authorized_client_scope(db, session)[:limit]] @router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 0988b15..567aa2a 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from app.api.deps import AuthSession, require_client_session +from app.api.deps import AuthSession, require_client_module_access from app.db.session import get_db from app.models.mix import Mix from app.models.product import Product @@ -35,7 +35,7 @@ def _can(session: AuthSession, module_key: str) -> bool: @router.get("/summary") def dashboard_summary( - session: AuthSession = Depends(require_client_session), + session: AuthSession = Depends(require_client_module_access("dashboard")), db: Session = Depends(get_db), ): raw_materials_summary: dict | None = None diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index de153d7..5b9a8b5 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,8 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -13,14 +12,14 @@ from app.core.access import ( get_user_permissions, permissions_to_module_map, ) +from app.core.http import ADMIN_AUTH_COOKIE, CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token +from app.core.security_logging import log_security_event from app.core.security import verify_token from app.db.session import get_db from app.models.access import Role, User from app.models.client_access import ClientFeatureAccess, ClientUser from app.services.client_access_service import has_access_level, module_access_map -bearer_scheme = HTTPBearer(auto_error=False) - @dataclass(frozen=True) class AuthSession: @@ -67,13 +66,16 @@ def _build_internal_auth_session(db: Session, payload: dict) -> AuthSession: def get_auth_session( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + request: Request, db: Session = Depends(get_db), ) -> AuthSession: - if credentials is None: + token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) or get_bearer_or_cookie_token( + request, cookie_name=ADMIN_AUTH_COOKIE.name + ) + if token is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") - payload = verify_token(credentials.credentials) + payload = verify_token(token) # Internal Hunter Stock Feeds users get an auth session derived from the # role/permission tables rather than the client-portal ClientUser tables. @@ -111,6 +113,7 @@ def require_client_session(session: AuthSession = Depends(get_auth_session)) -> def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession: if session.role != "admin": + log_security_event("authz.denied", role=session.role, required="admin") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return session @@ -143,6 +146,7 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"): if session.role == "internal": permissions = session.module_permissions or {} if not has_access_level(permissions.get(module_key), minimum_level): + log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted", @@ -158,10 +162,12 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"): ) ) if feature is not None and not feature.enabled: + log_security_event("authz.denied", role=session.role, module=module_key, reason="feature_disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} is disabled for this client") permissions = module_access_map(user) if not has_access_level(permissions.get(module_key), minimum_level): + log_security_event("authz.denied", role=session.role, module=module_key, access_level=minimum_level) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{module_key} access is not permitted") return AuthSession( @@ -190,6 +196,7 @@ def require_client_access_manager_session( user = load_current_client_user(db, require_client_session(session)) permissions = module_access_map(user) if user.role != "superadmin" or not has_access_level(permissions.get("client_access"), "manage"): + log_security_event("authz.denied", role=session.role, module="client_access", access_level="manage") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin client access is required") return AuthSession( diff --git a/backend/app/api/mix_calculator.py b/backend/app/api/mix_calculator.py index eab437b..6f4549f 100644 --- a/backend/app/api/mix_calculator.py +++ b/backend/app/api/mix_calculator.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from app.api.deps import AuthSession, require_client_module_access @@ -37,10 +37,11 @@ def mix_calculator_options( @router.get("", response_model=list[MixCalculatorSessionSummaryRead]) def mix_calculator_sessions( + limit: int = Query(default=100, ge=1, le=200), session: AuthSession = Depends(require_client_module_access("mix_calculator")), db: Session = Depends(get_db), ): - return list_mix_calculator_sessions(db, auth_session=session) + return list_mix_calculator_sessions(db, auth_session=session, limit=limit) @router.post("/preview", response_model=MixCalculatorPreviewRead) diff --git a/backend/app/api/mixes.py b/backend/app/api/mixes.py index 0ed1e63..a279f4b 100644 --- a/backend/app/api/mixes.py +++ b/backend/app/api/mixes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -13,8 +13,12 @@ router = APIRouter(prefix="/api/mixes", tags=["mixes"]) @router.get("", response_model=list[MixRead]) -def list_mixes(session: AuthSession = Depends(require_client_module_access("mix_master")), db: Session = Depends(get_db)): - mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)).all() +def list_mixes( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("mix_master")), + db: Session = Depends(get_db), +): + mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name).limit(limit)).all() return [calculate_mix_cost(db, mix.id) for mix in mixes] diff --git a/backend/app/api/products.py b/backend/app/api/products.py index 528a368..28f1c1b 100644 --- a/backend/app/api/products.py +++ b/backend/app/api/products.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -23,6 +23,7 @@ def _serialize_product(product: Product) -> dict: "mix_name": product.mix.name if product.mix else "", "sale_type": product.sale_type, "own_bag": product.own_bag, + "visible": product.visible, "unit_of_measure": product.unit_of_measure, "items_per_pallet": product.items_per_pallet, "bagging_process": product.bagging_process, @@ -34,8 +35,12 @@ def _serialize_product(product: Product) -> dict: @router.get("", response_model=list[ProductRead]) -def list_products(session: AuthSession = Depends(require_client_module_access("products")), db: Session = Depends(get_db)): - products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name)).all() +def list_products( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("products")), + db: Session = Depends(get_db), +): + products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).order_by(Product.name).limit(limit)).all() return [_serialize_product(product) for product in products] diff --git a/backend/app/api/raw_materials.py b/backend/app/api/raw_materials.py index 5047662..63bdf49 100644 --- a/backend/app/api/raw_materials.py +++ b/backend/app/api/raw_materials.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload @@ -34,12 +34,17 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d @router.get("", response_model=list[RawMaterialRead]) -def list_raw_materials(session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): +def list_raw_materials( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("raw_materials")), + db: Session = Depends(get_db), +): materials = db.scalars( select(RawMaterial) .where(RawMaterial.tenant_id == session.tenant_id) .options(selectinload(RawMaterial.price_versions)) .order_by(RawMaterial.name) + .limit(limit) ).all() return [serialize_raw_material(material) for material in materials] @@ -130,7 +135,12 @@ def add_price_version( @router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead]) -def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_module_access("raw_materials")), db: Session = Depends(get_db)): +def get_price_history( + raw_material_id: int, + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("raw_materials")), + db: Session = Depends(get_db), +): material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)) if material is None: raise HTTPException(status_code=404, detail="Raw material not found") @@ -141,6 +151,7 @@ def get_price_history(raw_material_id: int, session: AuthSession = Depends(requi RawMaterialPriceVersion.tenant_id == session.tenant_id, ) .order_by(RawMaterialPriceVersion.effective_date.desc()) + .limit(limit) ).all() items = [] for price in prices: diff --git a/backend/app/api/scenarios.py b/backend/app/api/scenarios.py index 22339ab..e6c786d 100644 --- a/backend/app/api/scenarios.py +++ b/backend/app/api/scenarios.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -12,8 +12,12 @@ router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) @router.get("", response_model=list[ScenarioRead]) -def list_scenarios(session: AuthSession = Depends(require_client_module_access("scenarios")), db: Session = Depends(get_db)): - return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all() +def list_scenarios( + limit: int = Query(default=100, ge=1, le=200), + session: AuthSession = Depends(require_client_module_access("scenarios")), + db: Session = Depends(get_db), +): + return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc()).limit(limit)).all() @router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED) diff --git a/backend/app/core/access.py b/backend/app/core/access.py index 4ba32b1..4ba950c 100644 --- a/backend/app/core/access.py +++ b/backend/app/core/access.py @@ -16,18 +16,16 @@ from __future__ import annotations from typing import Iterable -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi import Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.core.security import verify_token +from app.core.http import CLIENT_AUTH_COOKIE, get_bearer_or_cookie_token +from app.core.security_logging import log_security_event from app.db.session import get_db from app.models.access import Permission, Role, User - -bearer_scheme = HTTPBearer(auto_error=False) - # Subject claim used by tokens issued for internal Hunter Stock Feeds users. # Distinct from the existing client-portal/admin tokens so the two systems # cannot impersonate each other. @@ -103,7 +101,7 @@ def _load_user(db: Session, user_id: int) -> User | None: def get_current_user( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + request: Request, db: Session = Depends(get_db), ) -> User: """Resolve the current internal user from the bearer token. @@ -111,10 +109,11 @@ def get_current_user( Raises 401 for missing/invalid tokens or unknown users, 403 for inactive users. """ - if credentials is None: + token = get_bearer_or_cookie_token(request, cookie_name=CLIENT_AUTH_COOKIE.name) + if token is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") - payload = verify_token(credentials.credentials) + payload = verify_token(token) if payload.get("sub") != INTERNAL_USER_SUBJECT: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") @@ -136,6 +135,7 @@ def require_permission(permission_key: str): def dependency(user: User = Depends(get_current_user)) -> User: if not user_has_permission(user, permission_key): + log_security_event("authz.denied", role=user.role.name if user.role else None, permission=permission_key) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing required permission: {permission_key}", @@ -152,6 +152,7 @@ def require_any_permission(permission_keys: Iterable[str]): def dependency(user: User = Depends(get_current_user)) -> User: granted = get_user_permissions(user) if not any(key in granted for key in keys): + log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=list(keys)) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires any of: {list(keys)}", @@ -169,6 +170,7 @@ def require_all_permissions(permission_keys: Iterable[str]): granted = get_user_permissions(user) missing = [key for key in keys if key not in granted] if missing: + log_security_event("authz.denied", role=user.role.name if user.role else None, permissions=missing) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing required permissions: {missing}", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 624bd3b..a2d6303 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,9 +16,21 @@ def _parse_csv_env(value: str) -> tuple[str, ...]: return tuple(part.strip() for part in value.split(",") if part.strip()) +def _env_flag(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + @dataclass(frozen=True) class Settings: app_name: str + app_env: str + host: str + port: int + log_level: str + log_verbose: bool database_url: str client_name: str client_email: str @@ -30,11 +42,27 @@ class Settings: auth_secret: str cors_allow_origins: tuple[str, ...] cors_allow_origin_regex: str + session_ttl_seconds: int + session_cookie_name: str + admin_session_cookie_name: str + session_cookie_secure: bool + session_cookie_samesite: str + session_cookie_domain: str | None + request_body_max_bytes: int + login_rate_limit_attempts: int + login_rate_limit_window_seconds: int + trusted_hosts: tuple[str, ...] + docs_enabled: bool @classmethod def from_env(cls) -> "Settings": - return cls( + settings = cls( app_name=os.getenv("APP_NAME", "Data Entry App API"), + app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")), + host=os.getenv("HOST", "0.0.0.0"), + port=int(os.getenv("PORT", "8000")), + log_level=os.getenv("LOG_LEVEL", "DEBUG" if os.getenv("LOG_VERBOSE") in {"1", "true", "TRUE", "yes", "on"} else "INFO"), + log_verbose=_env_flag("LOG_VERBOSE"), database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"), client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"), client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"), @@ -51,7 +79,47 @@ class Settings: ) ), cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX), + session_ttl_seconds=int(os.getenv("SESSION_TTL_SECONDS", str(60 * 60 * 12))), + session_cookie_name=os.getenv("SESSION_COOKIE_NAME", "client_session"), + admin_session_cookie_name=os.getenv("ADMIN_SESSION_COOKIE_NAME", "admin_session"), + session_cookie_secure=_env_flag("SESSION_COOKIE_SECURE"), + session_cookie_samesite=os.getenv("SESSION_COOKIE_SAMESITE", "lax").lower(), + session_cookie_domain=os.getenv("SESSION_COOKIE_DOMAIN", "").strip() or None, + request_body_max_bytes=int(os.getenv("REQUEST_BODY_MAX_BYTES", str(1024 * 1024))), + login_rate_limit_attempts=int(os.getenv("LOGIN_RATE_LIMIT_ATTEMPTS", "8")), + login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")), + trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")), + docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"), ) + settings._validate() + return settings + + def _validate(self) -> None: + if self.session_cookie_samesite not in {"lax", "strict", "none"}: + raise ValueError("SESSION_COOKIE_SAMESITE must be one of: lax, strict, none") + + is_production = self.app_env.lower() == "production" + if not is_production: + return + + if self.client_password in {"changeme", "", "replace-with-strong-password"}: + raise ValueError("CLIENT_PASSWORD must be set to a non-default value in production") + if self.admin_password in {"lean101-admin", "", "replace-with-strong-password"}: + raise ValueError("ADMIN_PASSWORD must be set to a non-default value in production") + if self.auth_secret in {"lean-101-local-dev-secret", "change-me-in-production", "", "replace-with-a-long-random-secret"}: + raise ValueError("AUTH_SECRET must be set to a strong production secret") + if len(self.auth_secret) < 32: + raise ValueError("AUTH_SECRET must be at least 32 characters in production") + if not self.session_cookie_secure: + raise ValueError("SESSION_COOKIE_SECURE must be enabled in production") + if not self.cors_allow_origins: + raise ValueError("CORS_ALLOW_ORIGINS must explicitly list production origins") + if "localhost" in ",".join(self.cors_allow_origins).lower(): + raise ValueError("CORS_ALLOW_ORIGINS cannot include localhost in production") + if self.cors_allow_origin_regex == DEFAULT_CORS_ALLOW_ORIGIN_REGEX: + raise ValueError("CORS_ALLOW_ORIGIN_REGEX must be overridden or blank in production") + if self.docs_enabled: + raise ValueError("DOCS_ENABLED must be false in production") settings = Settings.from_env() diff --git a/backend/app/core/http.py b/backend/app/core/http.py new file mode 100644 index 0000000..fd75a0e --- /dev/null +++ b/backend/app/core/http.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from fastapi import Request, Response + +from app.core.config import settings + + +COOKIE_PATH: Final[str] = "/" + + +@dataclass(frozen=True) +class AuthCookie: + name: str + + def apply(self, response: Response, token: str) -> None: + response.set_cookie( + key=self.name, + value=token, + httponly=True, + secure=settings.session_cookie_secure, + samesite=settings.session_cookie_samesite, + domain=settings.session_cookie_domain, + path=COOKIE_PATH, + max_age=settings.session_ttl_seconds, + ) + + def clear(self, response: Response) -> None: + response.delete_cookie( + key=self.name, + domain=settings.session_cookie_domain, + path=COOKIE_PATH, + ) + + +CLIENT_AUTH_COOKIE = AuthCookie(settings.session_cookie_name) +ADMIN_AUTH_COOKIE = AuthCookie(settings.admin_session_cookie_name) + + +def get_bearer_or_cookie_token(request: Request, *, cookie_name: str) -> str | None: + authorization = request.headers.get("authorization", "").strip() + if authorization.lower().startswith("bearer "): + token = authorization[7:].strip() + if token: + return token + cookie_value = request.cookies.get(cookie_name) + if cookie_value: + return cookie_value + return None diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..1d97a6b --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging +import os +import sys +import time +from typing import Iterable + +try: + from rich.console import Console + from rich.logging import RichHandler + from rich.table import Table + from rich.text import Text +except ImportError: # pragma: no cover - exercised only before dependency install + Console = None + RichHandler = None + Table = None + Text = None + + +@dataclass(frozen=True) +class LoggingSettings: + app_name: str + app_env: str + host: str + port: int + log_level: str + log_verbose: bool + database_url: str + version: str + + +@dataclass(frozen=True) +class StartupStatus: + app_name: str + version: str + environment: str + host: str + port: int + database: str + mode: str + started_at: str + local_url: str + network_url: str + + +class PlainFormatter(logging.Formatter): + default_time_format = "%Y-%m-%d %H:%M:%S" + + def format(self, record: logging.LogRecord) -> str: + if not hasattr(record, "component"): + record.component = record.name.rsplit(".", 1)[-1] + return super().format(record) + + +def _allow_color() -> bool: + if RichHandler is None or Console is None: + return False + if os.getenv("NO_COLOR"): + return False + if os.getenv("TERM") == "dumb": + return False + return hasattr(sys.stderr, "isatty") and sys.stderr.isatty() + + +def _allow_unicode() -> bool: + encoding = (getattr(sys.stdout, "encoding", None) or "").lower() + if not encoding: + return False + return "utf" in encoding + + +def _console() -> Console: + return Console( + stderr=True, + soft_wrap=False, + highlight=False, + force_terminal=_allow_color(), + no_color=not _allow_color(), + emoji=False, + ) + + +def _rich_handler(level: str) -> RichHandler: + return RichHandler( + level=level, + console=_console(), + show_time=True, + show_level=True, + show_path=False, + omit_repeated_times=False, + markup=True, + rich_tracebacks=True, + tracebacks_show_locals=False, + log_time_format="%H:%M:%S", + ) + + +def _plain_handler(level: str) -> logging.StreamHandler: + handler = logging.StreamHandler() + handler.setLevel(level) + handler.setFormatter( + PlainFormatter("%(asctime)s | %(levelname)-7s | %(component)-10s | %(message)s") + ) + return handler + + +def _handler(level: str) -> logging.Handler: + return _rich_handler(level) if _allow_color() else _plain_handler(level) + + +def configure_logging(settings: LoggingSettings) -> None: + level = settings.log_level.upper() + root = logging.getLogger() + root.handlers.clear() + root.setLevel(level) + root.addHandler(_handler(level)) + + for name in ("uvicorn", "uvicorn.error", "fastapi"): + logger = logging.getLogger(name) + logger.handlers.clear() + logger.setLevel(level) + logger.propagate = True + + access_logger = logging.getLogger("uvicorn.access") + access_logger.handlers.clear() + access_logger.propagate = False + access_logger.disabled = True + + +def get_logger(name: str) -> logging.LoggerAdapter[logging.Logger]: + component = name.rsplit(".", 1)[-1] + return logging.LoggerAdapter(logging.getLogger(name), {"component": component}) + + +def _icon(name: str) -> str: + ascii_icons = { + "app": "#", + "info": "i", + "success": "+", + "warning": "!", + "error": "x", + "debug": ".", + "section": "=", + "url": ">", + "shutdown": "-", + } + unicode_icons = { + "app": "◆", + "info": "ℹ", + "success": "✓", + "warning": "▲", + "error": "✖", + "debug": "•", + "section": "─", + "url": "↳", + "shutdown": "◦", + } + icons = unicode_icons if _allow_unicode() else ascii_icons + return icons[name] + + +def _style(name: str) -> str: + return { + "info": "bold cyan", + "success": "bold green", + "warning": "bold yellow", + "error": "bold red", + "debug": "dim", + "section": "bold bright_blue", + "muted": "grey62", + }[name] + + +def section_heading(title: str) -> None: + logger = get_logger("data_entry_app.section") + if _allow_color(): + _console().rule(Text(f" {title.upper()} ", style=_style("section"))) + return + logger.info("%s %s %s", _icon("section") * 10, title.upper(), _icon("section") * 10) + + +def startup_banner(status: StartupStatus) -> None: + logger = get_logger("data_entry_app.startup") + if _allow_color(): + console = _console() + table = Table.grid(expand=False) + table.add_column(style="bold white", justify="left") + table.add_column(style="white", justify="left") + table.add_row("Environment", status.environment) + table.add_row("Version", status.version) + table.add_row("Host", status.host) + table.add_row("Port", str(status.port)) + table.add_row("Database", status.database) + table.add_row("Mode", status.mode) + table.add_row("Started", status.started_at) + + console.rule(Text(f" {status.app_name} ", style="bold white")) + console.print(Text("Clean startup. Clear status. Ready.", style="italic cyan")) + console.print(table) + console.print() + console.print(Text("App is running at:", style="bold white")) + console.print(Text(f" Local: {status.local_url}", style="cyan")) + console.print(Text(f" Network: {status.network_url}", style="cyan")) + console.print() + return + + logger.info("%s %s", _icon("app"), "Startup banner") + logger.info("App : %s", status.app_name) + logger.info("Environment : %s", status.environment) + logger.info("Version : %s", status.version) + logger.info("Host : %s", status.host) + logger.info("Port : %s", status.port) + logger.info("Database : %s", status.database) + logger.info("Mode : %s", status.mode) + logger.info("Started : %s", status.started_at) + logger.info("Local : %s", status.local_url) + logger.info("Network : %s", status.network_url) + + +def status_message(level: str, message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + palette = { + "debug": logging.DEBUG, + "info": logging.INFO, + "success": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + } + labels = { + "debug": f"[{_icon('debug')}]", + "info": f"[{_icon('info')}]", + "success": f"[{_icon('success')}]", + "warning": f"[{_icon('warning')}]", + "error": f"[{_icon('error')}]", + } + styles = { + "debug": _style("debug"), + "info": _style("info"), + "success": _style("success"), + "warning": _style("warning"), + "error": _style("error"), + } + logger = get_logger(logger_name) + rendered = message % args if args else message + if _allow_color(): + logger.log(palette[level], f"[{styles[level]}]{labels[level]}[/] {rendered}") + else: + logger.log(palette[level], "%s %s", labels[level], rendered) + + +def success(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("success", message, *args, logger_name=logger_name) + + +def warning(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("warning", message, *args, logger_name=logger_name) + + +def info(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("info", message, *args, logger_name=logger_name) + + +def debug(message: str, *args: object, logger_name: str = "data_entry_app.status") -> None: + status_message("debug", message, *args, logger_name=logger_name) + + +def fatal(message: str, *args: object, exc_info: bool = False, logger_name: str = "data_entry_app.status") -> None: + logger = get_logger(logger_name) + rendered = message % args if args else message + if _allow_color(): + logger.error(f"[{_style('error')}][{_icon('error')}][/] {rendered}", exc_info=exc_info) + else: + logger.error("[%s] %s", _icon("error"), rendered, exc_info=exc_info) + + +def shutdown_summary(*, uptime_seconds: float, requests_served: int, host: str, port: int) -> None: + section_heading("Shutdown") + logger = get_logger("data_entry_app.shutdown") + summary = f"Uptime {uptime_seconds:.1f}s | Requests {requests_served} | Endpoint http://{host}:{port}" + if _allow_color(): + logger.info(f"[{_style('debug')}]{_icon('shutdown')}[/] {summary}") + else: + logger.info("%s %s", _icon("shutdown"), summary) + + +def describe_database(url: str) -> str: + if url.startswith("sqlite"): + return "sqlite" + if "postgresql" in url: + return "postgresql" + if "mysql" in url: + return "mysql" + return url.split(":", 1)[0] + + +def sanitize_database_target(url: str) -> str: + if url.startswith("sqlite:///"): + return url.removeprefix("sqlite:///") + if "@" in url: + return url.split("@", 1)[1] + return url + + +def startup_status(settings: LoggingSettings) -> StartupStatus: + host = settings.host + local_host = "localhost" if host in {"0.0.0.0", "::"} else host + timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + return StartupStatus( + app_name=settings.app_name, + version=settings.version, + environment=settings.app_env, + host=settings.host, + port=settings.port, + database=f"{describe_database(settings.database_url)} ({sanitize_database_target(settings.database_url)})", + mode="verbose" if settings.log_verbose else "normal", + started_at=timestamp, + local_url=f"http://{local_host}:{settings.port}", + network_url=f"http://{host}:{settings.port}", + ) + + +def route_summary(routes: Iterable[object]) -> tuple[int, list[str]]: + lines: list[str] = [] + count = 0 + for route in routes: + path = getattr(route, "path", None) + methods = getattr(route, "methods", None) + if not path or not methods: + continue + filtered_methods = sorted(method for method in methods if method not in {"HEAD", "OPTIONS"}) + if not filtered_methods: + continue + count += 1 + lines.append(f"{','.join(filtered_methods):<7} {path}") + return count, lines + + +def log_request( + *, + method: str, + path: str, + status_code: int, + duration_ms: float, + client: str, + content_length: str | None, +) -> None: + level = "info" + if status_code >= 500: + level = "error" + elif status_code >= 400: + level = "warning" + elif path == "/health": + level = "debug" + + message = ( + f"{method:<6} {status_code:>3} {duration_ms:>7.1f}ms " + f"{path:<36} client={client}" + ) + if content_length: + message += f" bytes={content_length}" + status_message(level, message, logger_name="data_entry_app.http") + + +class RequestTimer: + def __init__(self) -> None: + self.started = time.perf_counter() + + @property + def elapsed_ms(self) -> float: + return (time.perf_counter() - self.started) * 1000 diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..485d075 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass +from threading import Lock + +from fastapi import HTTPException, Request, status + + +@dataclass +class SlidingWindowRateLimiter: + limit: int + window_seconds: int + + def __post_init__(self) -> None: + self._events: dict[str, deque[float]] = {} + self._lock = Lock() + + def hit(self, key: str) -> None: + now = time.time() + floor = now - self.window_seconds + + with self._lock: + bucket = self._events.setdefault(key, deque()) + while bucket and bucket[0] <= floor: + bucket.popleft() + if len(bucket) >= self.limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many requests. Please try again later.", + ) + bucket.append(now) + + +def request_client_key(request: Request, *, suffix: str = "") -> str: + forwarded_for = request.headers.get("x-forwarded-for", "") + client_ip = forwarded_for.split(",", 1)[0].strip() if forwarded_for else (request.client.host if request.client else "unknown") + return f"{client_ip}:{suffix}" if suffix else client_ip diff --git a/backend/app/core/security_logging.py b/backend/app/core/security_logging.py new file mode 100644 index 0000000..985978b --- /dev/null +++ b/backend/app/core/security_logging.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import logging + + +logger = logging.getLogger("data_entry_app.security") + + +def log_security_event(event: str, **fields: object) -> None: + safe_fields = { + key: value + for key, value in fields.items() + if key not in {"password", "token", "cookie", "authorization"} + } + logger.info("%s | %s", event, safe_fields) diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 7c0fc52..84d52a1 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -2,9 +2,19 @@ from __future__ import annotations from dataclasses import dataclass, field -from sqlalchemy import MetaData, inspect, text +from sqlalchemy import MetaData, bindparam, inspect, text from sqlalchemy.engine import Engine +HIDDEN_PRODUCT_CLIENTS = ( + "Bird Grits", + "Chaff", + "Hay & Straw", + "Hunter Premium Produce", + "Straight Grain", + "Uncategorized", + "Uncategorised", +) + TENANT_TABLES = { "client_users": None, @@ -88,6 +98,7 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]: # introduced on the model. Each entry is (table, column, DDL fragment). _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( ("users", "password_hash", "VARCHAR(255)"), + ("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"), ) @@ -359,6 +370,24 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]: return synced_rows +def sync_product_visibility(engine: Engine) -> int: + if not _table_exists(engine, "products") or not _has_column(engine, "products", "visible"): + return 0 + + with engine.begin() as connection: + result = connection.execute( + text( + """ + UPDATE products + SET visible = FALSE + WHERE client_name IN :hidden_clients + AND (visible IS NULL OR visible != FALSE) + """ + ).bindparams(bindparam("hidden_clients", value=HIDDEN_PRODUCT_CLIENTS, expanding=True)) + ) + return result.rowcount or 0 + + def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport: created_tables = ensure_metadata_tables(engine, metadata) added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine) diff --git a/backend/app/main.py b/backend/app/main.py index a7a43d8..4adcf04 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,23 @@ import logging -import os +import re import sys from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError, version as package_version from pathlib import Path from threading import Lock +from typing import Final if __package__ in {None, ""}: sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from fastapi import FastAPI +from fastapi import Request +from fastapi import FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from fastapi.responses import JSONResponse import uvicorn +from app import models as _models # noqa: F401 - ensure all SQLAlchemy models are registered from app.api.access import router as access_router from app.api.auth import router as auth_router from app.api.client_access import router as client_access_router @@ -23,13 +29,64 @@ from app.api.products import router as products_router 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.core.logging import ( + LoggingSettings, + RequestTimer, + configure_logging, + debug, + fatal, + info, + log_request, + route_summary, + section_heading, + shutdown_summary, + startup_banner, + startup_status, + success, +) from app.db.session import Base, engine -from app.db.migrations import MigrationReport, bootstrap_schema, sync_tenant_ids +from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids from app.seed import seed_if_empty + +def _resolve_version() -> str: + try: + return package_version("data-entry-app-backend") + except PackageNotFoundError: + return "0.0.0" + + +APP_VERSION: Final[str] = _resolve_version() +_logging_settings = LoggingSettings( + app_name=settings.app_name, + app_env=settings.app_env, + host=settings.host, + port=settings.port, + log_level=settings.log_level, + log_verbose=settings.log_verbose, + database_url=settings.database_url, + version=APP_VERSION, +) + +configure_logging(_logging_settings) + logger = logging.getLogger("data_entry_app.startup") _database_ready = False _database_ready_lock = Lock() +_requests_served = 0 + + +def _origin_is_allowed(origin: str | None) -> bool: + if not origin: + return True + + if origin in settings.cors_allow_origins: + return True + + if settings.cors_allow_origin_regex: + return re.fullmatch(settings.cors_allow_origin_regex, origin) is not None + + return False def ensure_database_ready() -> MigrationReport: @@ -45,11 +102,15 @@ def ensure_database_ready() -> MigrationReport: schema_report = bootstrap_schema(engine, Base.metadata) seed_if_empty() tenant_sync_report = sync_tenant_ids(engine) + hidden_product_count = sync_product_visibility(engine) report = MigrationReport( created_tables=schema_report.created_tables, added_columns=schema_report.added_columns, - synced_tenant_rows=tenant_sync_report, + synced_tenant_rows={ + **tenant_sync_report, + **({"products_visibility": hidden_product_count} if hidden_product_count else {}), + }, ) logger.info("Database startup checks complete: %s", report.summary()) _database_ready = True @@ -57,20 +118,72 @@ def ensure_database_ready() -> MigrationReport: @asynccontextmanager -async def lifespan(_: FastAPI): - ensure_database_ready() +async def lifespan(app: FastAPI): + started = startup_status(_logging_settings) + launch_time = RequestTimer() + + startup_banner(started) + section_heading("Startup") + info("Booting %s", settings.app_name, logger_name="data_entry_app.startup") + + section_heading("Configuration") + success("Configuration loaded") + info("CORS origins: %s", ", ".join(settings.cors_allow_origins), logger_name="data_entry_app.config") + if settings.cors_allow_origin_regex: + debug("CORS regex: %s", settings.cors_allow_origin_regex, logger_name="data_entry_app.config") + + section_heading("Database") + try: + report = ensure_database_ready() + except Exception: + fatal("Database startup failed", exc_info=True, logger_name="data_entry_app.database") + raise + success("Database connected") + if report.has_changes(): + info(report.summary(), logger_name="data_entry_app.database") + else: + debug(report.summary(), logger_name="data_entry_app.database") + + section_heading("Routes") + route_count, route_lines = route_summary(app.routes) + success("Routes registered (%s endpoints)", route_count) + if settings.log_verbose: + for route_line in route_lines: + debug(route_line, logger_name="data_entry_app.routes") + + section_heading("Services") + success("HTTP API ready") + info("Docs available at /docs", logger_name="data_entry_app.services") + info("Health probe available at /health", logger_name="data_entry_app.services") + yield + shutdown_summary( + uptime_seconds=launch_time.elapsed_ms / 1000, + requests_served=_requests_served, + host=settings.host, + port=settings.port, + ) -app = FastAPI(title=settings.app_name, lifespan=lifespan) + +app = FastAPI( + title=settings.app_name, + version=APP_VERSION, + lifespan=lifespan, + docs_url="/docs" if settings.docs_enabled else None, + redoc_url=None, + openapi_url="/openapi.json" if settings.docs_enabled else None, +) + +app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(settings.trusted_hosts) or ["*"]) app.add_middleware( CORSMiddleware, allow_origins=list(settings.cors_allow_origins), - allow_origin_regex=settings.cors_allow_origin_regex, + allow_origin_regex=settings.cors_allow_origin_regex or None, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Requested-With"], ) app.include_router(auth_router) @@ -85,6 +198,89 @@ app.include_router(scenarios_router) app.include_router(powerbi_router) +@app.middleware("http") +async def log_http_requests(request: Request, call_next): + global _requests_served + + timer = RequestTimer() + try: + response = await call_next(request) + except Exception: + log_request( + method=request.method, + path=request.url.path, + status_code=500, + duration_ms=timer.elapsed_ms, + client=request.client.host if request.client else "-", + content_length=None, + ) + raise + + _requests_served += 1 + log_request( + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=timer.elapsed_ms, + client=request.client.host if request.client else "-", + content_length=response.headers.get("content-length"), + ) + return response + + +@app.middleware("http") +async def enforce_request_limits_and_csrf(request: Request, call_next): + content_length = request.headers.get("content-length") + if content_length: + try: + if int(content_length) > settings.request_body_max_bytes: + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content={"detail": "Request body is too large"}, + ) + except ValueError: + pass + + if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.cookies: + origin = request.headers.get("origin") + if not _origin_is_allowed(origin): + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": "Origin is not allowed"}, + ) + + response = await call_next(request) + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "img-src 'self' data:; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "font-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" + ) + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + if settings.app_env.lower() == "production": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + + +@app.exception_handler(HTTPException) +async def http_exception_handler(_: Request, exc: HTTPException): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(_: Request, exc: Exception): + fatal("Unhandled server error", exc_info=True, logger_name="data_entry_app.http") + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + + @app.get("/") def root(): return { @@ -117,9 +313,10 @@ def healthcheck(): if __name__ == "__main__": report = ensure_database_ready() - print(f"Database startup checks complete: {report.summary()}") + success("Database startup checks complete: %s", report.summary(), logger_name="data_entry_app.startup") uvicorn.run( app, - host=os.getenv("HOST", "0.0.0.0"), - port=int(os.getenv("PORT", "8000")), + host=settings.host, + port=settings.port, + access_log=False, ) diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 2796a2d..e86fed3 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -19,6 +19,7 @@ class Product(Base): mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id")) sale_type: Mapped[str] = mapped_column(String(64), default="standard") own_bag: Mapped[bool] = mapped_column(Boolean, default=False) + visible: Mapped[bool] = mapped_column(Boolean, default=True) unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag") items_per_pallet: Mapped[int] = mapped_column(Integer, default=50) bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -31,4 +32,3 @@ class Product(Base): from app.models.mix import Mix # noqa: E402 - diff --git a/backend/app/schemas/client_access.py b/backend/app/schemas/client_access.py index a7cc861..1f63a7b 100644 --- a/backend/app/schemas/client_access.py +++ b/backend/app/schemas/client_access.py @@ -1,30 +1,34 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field class ClientUserCreate(BaseModel): + model_config = ConfigDict(extra="forbid") client_account_id: int - full_name: str - email: str + full_name: str = Field(min_length=1, max_length=255) + email: str = Field(min_length=3, max_length=255) role: str = "viewer" status: str = "invited" is_new_user: bool = True class ClientUserUpdate(BaseModel): - full_name: str | None = None - email: str | None = None + model_config = ConfigDict(extra="forbid") + full_name: str | None = Field(default=None, min_length=1, max_length=255) + email: str | None = Field(default=None, min_length=3, max_length=255) role: str | None = None status: str | None = None is_new_user: bool | None = None class ClientFeatureUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") enabled: bool class ClientUserModulePermissionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") access_level: str diff --git a/backend/app/schemas/mix.py b/backend/app/schemas/mix.py index 3476064..88065a5 100644 --- a/backend/app/schemas/mix.py +++ b/backend/app/schemas/mix.py @@ -4,14 +4,16 @@ from pydantic import BaseModel, ConfigDict, Field class MixIngredientCreate(BaseModel): + model_config = ConfigDict(extra="forbid") raw_material_id: int quantity_kg: float = Field(gt=0) - notes: str | None = None + notes: str | None = Field(default=None, max_length=1000) class MixIngredientUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") quantity_kg: float | None = Field(default=None, gt=0) - notes: str | None = None + notes: str | None = Field(default=None, max_length=1000) class MixIngredientRead(BaseModel): @@ -26,20 +28,22 @@ class MixIngredientRead(BaseModel): class MixCreate(BaseModel): - client_name: str - name: str + model_config = ConfigDict(extra="forbid") + client_name: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) status: str = "draft" version: int = 1 - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) ingredients: list[MixIngredientCreate] class MixUpdate(BaseModel): - client_name: str | None = None - name: str | None = None + model_config = ConfigDict(extra="forbid") + client_name: str | None = Field(default=None, min_length=1, max_length=255) + name: str | None = Field(default=None, min_length=1, max_length=255) status: str | None = None version: int | None = None - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class MixRead(BaseModel): @@ -57,4 +61,3 @@ class MixRead(BaseModel): mix_cost_per_kg: float | None warnings: list[str] model_config = ConfigDict(from_attributes=True) - diff --git a/backend/app/schemas/mix_calculator.py b/backend/app/schemas/mix_calculator.py index 7817595..98886fd 100644 --- a/backend/app/schemas/mix_calculator.py +++ b/backend/app/schemas/mix_calculator.py @@ -30,13 +30,14 @@ class MixCalculatorSessionLineRead(BaseModel): class MixCalculatorSessionBase(BaseModel): + model_config = ConfigDict(extra="forbid") mix_date: date - client_name: str + client_name: str = Field(min_length=1, max_length=255) product_id: int batch_size_kg: float = Field(gt=0) prepared_by_name: str = Field(min_length=1, max_length=255) status: str = "saved" - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class MixCalculatorSessionCreate(MixCalculatorSessionBase): @@ -44,13 +45,14 @@ class MixCalculatorSessionCreate(MixCalculatorSessionBase): class MixCalculatorSessionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") mix_date: date | None = None - client_name: str | None = None + client_name: str | None = Field(default=None, min_length=1, max_length=255) product_id: int | None = None batch_size_kg: float | None = Field(default=None, gt=0) prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255) status: str | None = None - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class MixCalculatorPreviewRead(BaseModel): diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index b273b16..2ca8c09 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -4,33 +4,37 @@ from pydantic import BaseModel, ConfigDict, Field class ProductCreate(BaseModel): - client_name: str - item_id: str | None = None - name: str + model_config = ConfigDict(extra="forbid") + client_name: str = Field(min_length=1, max_length=255) + item_id: str | None = Field(default=None, max_length=128) + name: str = Field(min_length=1, max_length=255) mix_id: int sale_type: str = "standard" own_bag: bool = False - unit_of_measure: str = "20kg bag" + visible: bool = True + unit_of_measure: str = Field(default="20kg bag", min_length=1, max_length=64) items_per_pallet: int = Field(default=50, gt=0) - bagging_process: str | None = None + bagging_process: str | None = Field(default=None, max_length=128) distributor_margin: float | None = Field(default=None, gt=0, lt=1) wholesale_margin: float | None = Field(default=None, gt=0, lt=1) - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class ProductUpdate(BaseModel): - client_name: str | None = None - item_id: str | None = None - name: str | None = None + model_config = ConfigDict(extra="forbid") + client_name: str | None = Field(default=None, min_length=1, max_length=255) + item_id: str | None = Field(default=None, max_length=128) + name: str | None = Field(default=None, min_length=1, max_length=255) mix_id: int | None = None sale_type: str | None = None own_bag: bool | None = None - unit_of_measure: str | None = None + visible: bool | None = None + unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64) items_per_pallet: int | None = Field(default=None, gt=0) - bagging_process: str | None = None + bagging_process: str | None = Field(default=None, max_length=128) distributor_margin: float | None = Field(default=None, gt=0, lt=1) wholesale_margin: float | None = Field(default=None, gt=0, lt=1) - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class ProductRead(BaseModel): @@ -43,6 +47,7 @@ class ProductRead(BaseModel): mix_name: str sale_type: str own_bag: bool + visible: bool unit_of_measure: str items_per_pallet: int bagging_process: str | None diff --git a/backend/app/schemas/raw_material.py b/backend/app/schemas/raw_material.py index d05d7a0..ec5eec1 100644 --- a/backend/app/schemas/raw_material.py +++ b/backend/app/schemas/raw_material.py @@ -4,11 +4,12 @@ from pydantic import BaseModel, ConfigDict, Field class RawMaterialPriceVersionCreate(BaseModel): + model_config = ConfigDict(extra="forbid") market_value: float = Field(gt=0) waste_percentage: float = Field(ge=0, default=0.0) effective_date: date status: str = "active" - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate): @@ -21,21 +22,23 @@ class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate): class RawMaterialCreate(BaseModel): - name: str - supplier: str | None = None - unit_of_measure: str + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=255) + supplier: str | None = Field(default=None, max_length=255) + unit_of_measure: str = Field(min_length=1, max_length=64) kg_per_unit: float = Field(gt=0) status: str = "active" - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) initial_price: RawMaterialPriceVersionCreate class RawMaterialUpdate(BaseModel): - supplier: str | None = None - unit_of_measure: str | None = None + model_config = ConfigDict(extra="forbid") + supplier: str | None = Field(default=None, max_length=255) + unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64) kg_per_unit: float | None = Field(default=None, gt=0) status: str | None = None - notes: str | None = None + notes: str | None = Field(default=None, max_length=2000) class RawMaterialRead(BaseModel): @@ -50,4 +53,3 @@ class RawMaterialRead(BaseModel): created_at: datetime current_price: RawMaterialPriceVersionRead | None model_config = ConfigDict(from_attributes=True) - diff --git a/backend/app/schemas/scenario.py b/backend/app/schemas/scenario.py index bf758fa..fa8f668 100644 --- a/backend/app/schemas/scenario.py +++ b/backend/app/schemas/scenario.py @@ -6,8 +6,9 @@ from app.schemas.product import ProductCostBreakdown class ScenarioCreate(BaseModel): - name: str - description: str | None = None + model_config = ConfigDict(extra="forbid") + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) overrides: dict = Field(default_factory=dict) diff --git a/backend/app/seed.py b/backend/app/seed.py index 1dbf349..411ec9f 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -25,6 +25,17 @@ WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1) WORKBOOK_SENTINEL_ITEM_ID = "404266" WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx" logger = logging.getLogger("data_entry_app.seed") +HIDDEN_PRODUCT_CLIENTS = frozenset( + { + "Bird Grits", + "Chaff", + "Hay & Straw", + "Hunter Premium Produce", + "Straight Grain", + "Uncategorized", + "Uncategorised", + } +) def _workbook_candidates() -> list[Path]: @@ -287,6 +298,7 @@ def _read_product_rows(workbook) -> list[dict]: "wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]), "process_label": _text(row[8]), "sheet_own_bag": _text(row[5]), + "visible": (_text(row[0]) or "General") not in HIDDEN_PRODUCT_CLIENTS, } ) @@ -569,6 +581,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str], mix_id=mix.id, sale_type=row["sale_type"], own_bag=row["own_bag"], + visible=row["visible"], unit_of_measure=row["unit_of_measure"], items_per_pallet=row["items_per_pallet"], bagging_process=row["bagging_process"], @@ -584,6 +597,7 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str], product.mix_id = mix.id product.sale_type = row["sale_type"] product.own_bag = row["own_bag"] + product.visible = row["visible"] product.unit_of_measure = row["unit_of_measure"] product.items_per_pallet = row["items_per_pallet"] product.bagging_process = row["bagging_process"] diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index 05c9492..463e265 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -27,7 +27,7 @@ def _build_session_access_query(session: AuthSession): def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None: return db.scalar( select(Product) - .where(Product.id == product_id, Product.tenant_id == tenant_id) + .where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True)) .options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material)) ) @@ -122,7 +122,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: products = db.scalars( select(Product) - .where(Product.tenant_id == tenant_id) + .where(Product.tenant_id == tenant_id, Product.visible.is_(True)) .options(joinedload(Product.mix)) .order_by(Product.client_name, Product.name) ).all() @@ -191,11 +191,12 @@ def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_ } -def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession) -> list[dict]: +def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession, limit: int = 100) -> list[dict]: sessions = db.scalars( _build_session_access_query(auth_session) .options(selectinload(MixCalculatorSession.lines)) .order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc()) + .limit(limit) ).all() return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3940c5b..1285c3b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.115,<1.0", "openpyxl>=3.1,<4.0", + "rich>=13.9,<15.0", "uvicorn[standard]>=0.30,<1.0", "sqlalchemy>=2.0,<3.0", "pydantic>=2.8,<3.0", diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index abbb8e9..2aa9617 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -6,7 +6,7 @@ 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.migrations import bootstrap_schema, sync_product_visibility, sync_tenant_ids from app.db.session import Base from app.main import app from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule @@ -17,7 +17,7 @@ from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material -from app.services.mix_calculator_service import calculate_mix_calculator_preview +from app.services.mix_calculator_service import build_mix_calculator_options, calculate_mix_calculator_preview def build_session() -> Session: @@ -151,6 +151,94 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags(): assert "not a whole-bag quantity" in preview["warnings"][0] +def test_mix_calculator_options_hide_invisible_products_and_clients(): + db = build_session() + + maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active") + db.add(maize) + db.flush() + + visible_mix = Mix(tenant_id="hunter-premium-produce", client_name="Peckish", name="Visible Mix", status="active", version=1) + hidden_mix = Mix(tenant_id="hunter-premium-produce", client_name="Chaff", name="Hidden Mix", status="active", version=1) + db.add_all([visible_mix, hidden_mix]) + db.flush() + + db.add_all( + [ + MixIngredient(tenant_id="hunter-premium-produce", mix_id=visible_mix.id, raw_material_id=maize.id, quantity_kg=20), + MixIngredient(tenant_id="hunter-premium-produce", mix_id=hidden_mix.id, raw_material_id=maize.id, quantity_kg=20), + ] + ) + db.flush() + + db.add_all( + [ + Product( + tenant_id="hunter-premium-produce", + client_name="Peckish", + name="Visible Product", + mix_id=visible_mix.id, + visible=True, + sale_type="standard", + own_bag=False, + unit_of_measure="20kg bag", + items_per_pallet=50, + ), + Product( + tenant_id="hunter-premium-produce", + client_name="Chaff", + name="Hidden Product", + mix_id=hidden_mix.id, + visible=False, + sale_type="standard", + own_bag=False, + unit_of_measure="20kg bag", + items_per_pallet=50, + ), + ] + ) + db.commit() + + options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce") + + assert options["clients"] == ["Peckish"] + assert [product["product_name"] for product in options["products"]] == ["Visible Product"] + + +def test_sync_product_visibility_hides_configured_clients(): + engine = create_engine("sqlite:///:memory:") + with engine.begin() as connection: + connection.execute( + text( + """ + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + client_name VARCHAR(255), + visible BOOLEAN NOT NULL DEFAULT TRUE + ) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO products (id, client_name, visible) + VALUES + (1, 'Chaff', TRUE), + (2, 'Peckish', TRUE), + (3, 'Uncategorized', TRUE) + """ + ) + ) + + updated = sync_product_visibility(engine) + + assert updated == 2 + with engine.connect() as connection: + rows = connection.execute(text("SELECT client_name, visible FROM products ORDER BY id")).all() + assert rows == [("Chaff", 0), ("Peckish", 1), ("Uncategorized", 0)] + + def test_root_and_login_endpoints(): with TestClient(app) as client: root_response = client.get("/") @@ -260,16 +348,15 @@ def test_client_access_endpoints(): "/api/auth/admin/login", json={"email": settings.admin_email, "password": settings.admin_password}, ) - token = login_response.json()["token"] - headers = {"Authorization": f"Bearer {token}"} + admin_cookies = {settings.admin_session_cookie_name: login_response.cookies.get(settings.admin_session_cookie_name)} - access_response = client.get("/api/client-access", headers=headers) + access_response = client.get("/api/client-access", cookies=admin_cookies) assert access_response.status_code == 200 assert len(access_response.json()) >= 1 assert "audit_history" in access_response.json()[0] assert "module_permissions" in access_response.json()[0]["users"][0] - export_response = client.get("/api/powerbi/client-access", headers=headers) + export_response = client.get("/api/powerbi/client-access", cookies=admin_cookies) assert export_response.status_code == 200 assert "client_rows" in export_response.json() assert "permission_rows" in export_response.json() @@ -278,8 +365,8 @@ def test_client_access_endpoints(): "/api/auth/client/login", json={"email": settings.client_email, "password": settings.client_password}, ) - client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"} - superadmin_access_response = client.get("/api/client-access", headers=client_headers) + client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)} + superadmin_access_response = client.get("/api/client-access", cookies=client_cookies) assert superadmin_access_response.status_code == 200 assert len(superadmin_access_response.json()) == 1 @@ -291,9 +378,9 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): json={"email": settings.client_email, "password": settings.client_password}, ) assert superadmin_login.status_code == 200 - superadmin_headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"} + superadmin_cookies = {settings.session_cookie_name: superadmin_login.cookies.get(settings.session_cookie_name)} - options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers) + options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) assert options_response.status_code == 200 options_payload = options_response.json() assert len(options_payload["products"]) >= 100 @@ -310,7 +397,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): "prepared_by_name": "Amelia Hart", "notes": "Morning production run", }, - headers=superadmin_headers, + cookies=superadmin_cookies, ) assert create_response.status_code == 201 created = create_response.json() @@ -323,7 +410,7 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): patch_response = client.patch( f"/api/mix-calculator/{created['id']}", json={"batch_size_kg": 550}, - headers=superadmin_headers, + cookies=superadmin_cookies, ) assert patch_response.status_code == 200 assert patch_response.json()["total_bags"] == 27.5 @@ -334,13 +421,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password}, ) assert operator_login.status_code == 200 - operator_headers = {"Authorization": f"Bearer {operator_login.json()['token']}"} + operator_cookies = {settings.session_cookie_name: operator_login.cookies.get(settings.session_cookie_name)} - operator_list_response = client.get("/api/mix-calculator", headers=operator_headers) + operator_list_response = client.get("/api/mix-calculator", cookies=operator_cookies) assert operator_list_response.status_code == 200 assert operator_list_response.json() == [] - operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", headers=operator_headers) + operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", cookies=operator_cookies) assert operator_detail_response.status_code == 404 @@ -350,9 +437,9 @@ def test_mix_calculator_pdf_endpoint_returns_pdf(): "/api/auth/client/login", json={"email": settings.client_email, "password": settings.client_password}, ) - headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"} + superadmin_cookies = {settings.session_cookie_name: superadmin_login.cookies.get(settings.session_cookie_name)} - options_response = client.get("/api/mix-calculator/options", headers=headers) + options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) seeded_product = next( product for product in options_response.json()["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg" ) @@ -367,11 +454,11 @@ def test_mix_calculator_pdf_endpoint_returns_pdf(): "prepared_by_name": "Amelia Hart", "notes": "Morning production run", }, - headers=headers, + cookies=superadmin_cookies, ) created = create_response.json() - pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", headers=headers) + pdf_response = client.get(f"/api/mix-calculator/{created['id']}/pdf", cookies=superadmin_cookies) assert pdf_response.status_code == 200 assert pdf_response.headers["content-type"] == "application/pdf" @@ -385,8 +472,8 @@ def test_module_permission_blocks_client_module_access(): "/api/auth/admin/login", json={"email": settings.admin_email, "password": settings.admin_password}, ) - admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"} - access_response = client.get("/api/client-access", headers=admin_headers) + admin_cookies = {settings.admin_session_cookie_name: admin_login_response.cookies.get(settings.admin_session_cookie_name)} + access_response = client.get("/api/client-access", cookies=admin_cookies) first_client = access_response.json()[0] first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email) @@ -396,15 +483,15 @@ def test_module_permission_blocks_client_module_access(): client.patch( f"/api/client-access/users/{first_user['id']}/module-permissions/{permission['module_key']}", json={"access_level": "none"}, - headers=admin_headers, + cookies=admin_cookies, ) client_login_response = client.post( "/api/auth/client/login", json={"email": settings.client_email, "password": settings.client_password}, ) - client_headers = {"Authorization": f"Bearer {client_login_response.json()['token']}"} - raw_materials_response = client.get("/api/raw-materials", headers=client_headers) + client_cookies = {settings.session_cookie_name: client_login_response.cookies.get(settings.session_cookie_name)} + raw_materials_response = client.get("/api/raw-materials", cookies=client_cookies) assert raw_materials_response.status_code == 403 diff --git a/deploy/nginx/clients.lean-101.conf b/deploy/nginx/clients.lean-101.conf index 621b793..6dccbcf 100644 --- a/deploy/nginx/clients.lean-101.conf +++ b/deploy/nginx/clients.lean-101.conf @@ -26,7 +26,8 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always; location /_app/immutable/ { expires 1y; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index c7a3adc..04b41a3 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -24,6 +24,7 @@ services: restart: unless-stopped environment: APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_ENV: ${APP_ENV:-production} DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} @@ -34,6 +35,16 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required} AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required} CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au} + CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-} + TRUSTED_HOSTS: ${TRUSTED_HOSTS:-clients.lean-101.com.au} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} + SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-lax} + SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-} + SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-43200} + REQUEST_BODY_MAX_BYTES: ${REQUEST_BODY_MAX_BYTES:-1048576} + LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8} + LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300} + DOCS_ENABLED: ${DOCS_ENABLED:-false} depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index d8019b8..8c3693d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: restart: unless-stopped environment: APP_NAME: ${APP_NAME:-Lean 101 Clients API} + APP_ENV: ${APP_ENV:-development} DATABASE_URL: ${DATABASE_URL:-sqlite:////data/data_entry_app.db} CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce} CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com} @@ -17,6 +18,16 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-lean101-admin} AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production} CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au} + CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-} + TRUSTED_HOSTS: ${TRUSTED_HOSTS:-localhost,127.0.0.1} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-lax} + SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN:-} + SESSION_TTL_SECONDS: ${SESSION_TTL_SECONDS:-43200} + REQUEST_BODY_MAX_BYTES: ${REQUEST_BODY_MAX_BYTES:-1048576} + LOGIN_RATE_LIMIT_ATTEMPTS: ${LOGIN_RATE_LIMIT_ATTEMPTS:-8} + LOGIN_RATE_LIMIT_WINDOW_SECONDS: ${LOGIN_RATE_LIMIT_WINDOW_SECONDS:-300} + DOCS_ENABLED: ${DOCS_ENABLED:-true} volumes: - clients_app_data:/data healthcheck: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index af6ced8..7810c50 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -14,10 +14,18 @@ ENV NODE_ENV=production WORKDIR /app +RUN addgroup --system app && adduser --system --ingroup app app + COPY --from=builder /app/build ./build COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/node_modules ./node_modules +RUN chown -R app:app /app + +USER app + EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 CMD node -e "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + CMD ["node", "build"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 879fdef..b01362e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { - "name": "data-entry-app-frontend", - "version": "0.1.5", + "name": "hunter-app", + "version": "1.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "data-entry-app-frontend", - "version": "0.1.5", + "name": "hunter-app", + "version": "1.5.6", "dependencies": { "lucide-svelte": "^1.0.1" }, @@ -15,7 +15,7 @@ "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.7.1", "svelte": "^5.0.0", - "typescript": "^5.5.4", + "typescript": "^5.9.3", "vite": "^8.0.0", "vitest": "^4.0.0" } diff --git a/frontend/package.json b/frontend/package.json index 91989cf..af3619b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "data-entry-app-frontend", + "name": "hunter-app", "version": "1.5.6", "private": true, "type": "module", @@ -14,7 +14,7 @@ "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.7.1", "svelte": "^5.0.0", - "typescript": "^5.5.4", + "typescript": "^5.9.3", "vite": "^8.0.0", "vitest": "^4.0.0" }, diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f474da7..db03f60 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -54,7 +54,7 @@ describe('api fetch injection', () => { 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(injectedFetch.mock.calls[0]?.[0]).toBe(path); expect(globalFetch).not.toHaveBeenCalled(); }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 411cc53..f931a36 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,7 +37,6 @@ import type { } from '$lib/types'; import { getStoredAdminSession, getStoredClientSession } from '$lib/session'; -const DEFAULT_API_PORT = env.PUBLIC_API_PORT || '8000'; const BACKEND_UNAVAILABLE_MESSAGE = 'Unable to reach the server. Check that the backend is running and try again.'; type AuthMode = 'none' | 'client' | 'admin' | 'manager'; @@ -51,40 +50,62 @@ 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}`; + const configuredBaseUrl = env.PUBLIC_API_BASE_URL?.trim(); + if (configuredBaseUrl) { + try { + const configuredUrl = new URL(configuredBaseUrl, window.location.origin); + // Keep browser API traffic same-origin by default. This avoids CORS, + // CSP `connect-src`, and cookie policy failures when the backend is + // reverse-proxied under `/api` on the same host. + if (configuredUrl.origin === window.location.origin || configuredUrl.hostname === window.location.hostname) { + return ''; + } + return configuredUrl.toString().replace(/\/+$/, ''); + } catch { + return ''; + } + } + + return ''; } - return `http://127.0.0.1:${DEFAULT_API_PORT}`; + const defaultApiPort = env.PUBLIC_API_PORT || '8000'; + return `http://127.0.0.1:${defaultApiPort}`; } function buildApiUrl(path: string) { return `${getApiBaseUrl()}${path}`; } -function getToken(auth: AuthMode) { - if (!browser) { - return null; - } - +function getSessionFingerprint(auth: AuthMode) { if (auth === 'client') { - return getStoredClientSession()?.token ?? null; + const session = getStoredClientSession(); + return session ? `${session.role}:${session.email}:${session.user_id ?? ''}` : ''; } if (auth === 'admin') { - return getStoredAdminSession()?.token ?? null; + const session = getStoredAdminSession(); + return session ? `${session.role}:${session.email}` : ''; } if (auth === 'manager') { - return getStoredAdminSession()?.token ?? getStoredClientSession()?.token ?? null; + const admin = getStoredAdminSession(); + if (admin) { + return `${admin.role}:${admin.email}`; + } + const client = getStoredClientSession(); + return client ? `${client.role}:${client.email}:${client.user_id ?? ''}` : ''; } - return null; + return ''; +} + +function resolveRequestUrl(path: string, fetcher: ApiFetch) { + if (fetcher !== fetch) { + return path; + } + return buildApiUrl(path); } function normalizeRequestError(error: unknown) { @@ -107,9 +128,8 @@ function normalizeRequestError(error: unknown) { async function fetchJson(path: string, fallback: T, auth: AuthMode = 'none', fetcher: ApiFetch = fetch): Promise { try { - const token = getToken(auth); - const response = await fetcher(buildApiUrl(path), { - headers: token ? { Authorization: `Bearer ${token}` } : undefined + const response = await fetcher(resolveRequestUrl(path, fetcher), { + credentials: 'include' }); if (!response.ok) { if (auth !== 'none') { @@ -136,8 +156,8 @@ const inflightRequests = new Map>(); const READ_CACHE_TTL_MS = 30_000; function makeCacheKey(path: string, auth: AuthMode) { - const token = browser ? getToken(auth) ?? '' : ''; - return `${auth}:${token.slice(-8)}:${path}`; + const sessionFingerprint = browser ? getSessionFingerprint(auth) : ''; + return `${auth}:${sessionFingerprint}:${path}`; } async function cachedFetchJson( @@ -189,13 +209,12 @@ async function request( fetcher: ApiFetch = fetch ): Promise { try { - const token = getToken(auth); - const response = await fetcher(buildApiUrl(path), { + const response = await fetcher(resolveRequestUrl(path, fetcher), { headers: { 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers ?? {}) }, + credentials: 'include', ...options }); @@ -218,6 +237,9 @@ async function request( // after the user creates or updates anything. clearApiCache(); } + if (response.status === 204) { + return undefined as T; + } return (await response.json()) as T; } catch (error) { throw normalizeRequestError(error); @@ -230,9 +252,8 @@ async function requestBlob( fetcher: ApiFetch = fetch ): Promise { try { - const token = getToken(auth); - const response = await fetcher(buildApiUrl(path), { - headers: token ? { Authorization: `Bearer ${token}` } : undefined + const response = await fetcher(resolveRequestUrl(path, fetcher), { + credentials: 'include' }); if (!response.ok) { @@ -326,6 +347,9 @@ export const api = { }), clientSession: (fetcher?: ApiFetch) => request('/api/auth/client/session', { method: 'GET' }, 'client', fetcher), adminSession: (fetcher?: ApiFetch) => request('/api/auth/admin/session', { method: 'GET' }, 'admin', fetcher), + clientLogout: () => request('/api/auth/client/logout', { method: 'POST' }, 'client'), + adminLogout: () => request('/api/auth/admin/logout', { method: 'POST' }, 'admin'), + internalLogout: () => request('/api/access/logout', { method: 'POST' }, 'client'), login: (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 549eb8f..a5081fd 100644 --- a/frontend/src/lib/components/AdminShell.svelte +++ b/frontend/src/lib/components/AdminShell.svelte @@ -1,6 +1,7 @@ + +{#if blocked} +
+

{label}

+

{title}

+

{detail}

+
+{:else} + {@render children()} +{/if} + + diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index bbb183a..c90d45c 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -1,5 +1,6 @@ - {pageTitle(page.url.pathname)} | Hunter Premium Produce + {shellTitle} | Hunter Premium Produce {#if !$clientSession} @@ -314,77 +386,98 @@ {#if !showBottomNav} clientSession.clear()} + onSignOut={signOut} /> {/if}
openPalette()} + {canUseWorkspaceSearch} + {canOpenSettings} + onOpenPalette={() => canUseWorkspaceSearch && openPalette()} onToggleUserMenu={() => { userMenuOpen = !userMenuOpen; quickMenuOpen = false; }} onOpenSettings={openSettings} - onSignOut={() => clientSession.clear()} + onSignOut={signOut} />
- {#if !isRootRoute && isRestoringSession} -
-

Checking Session

-

Restoring your client workspace.

-

Refreshing the current page with the saved browser session before deciding whether sign-in is required.

-
- {:else} + {@render children()} - {/if} +
{#if quickMenuOpen} {/if} - + {#if canOpenMixMaster || canCreateMixWorksheet || canOpenMixCalculator || canCreateMixSession || canOpenProducts || canUseWorkspaceSearch} + + {/if}
@@ -405,12 +498,11 @@ {#if navOpen} - + {/if} {/if} @@ -835,15 +937,10 @@ background: var(--line); } - .nav-sublist a { + .drawer-sublist a { position: relative; } - .bottom-nav-icon svg { - width: 0.9rem; - height: 0.9rem; - } - /* `.nav-icon.muted` is kept for the bottom-nav (still uses letter labels). */ .nav-icon.muted { color: #fff; @@ -1002,16 +1099,6 @@ color: var(--muted); } - .locked-card a { - display: inline-flex; - margin-top: 1rem; - padding: 0.78rem 0.92rem; - border-radius: 0.88rem; - background: var(--color-brand); - color: #fff; - font-weight: 600; - } - .palette-overlay { position: fixed; inset: 0; diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte index 4dacdae..442fc0b 100644 --- a/frontend/src/lib/components/Toast.svelte +++ b/frontend/src/lib/components/Toast.svelte @@ -3,7 +3,7 @@ function icon(type: Toast['type']) { if (type === 'success') return '✓'; - if (type === 'error') return '✕'; + if (type === 'error') return '!'; if (type === 'loading') return null; // spinner shown separately return 'ℹ'; } diff --git a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte index 0d62014..1621180 100644 --- a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte +++ b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte @@ -19,7 +19,7 @@ const todayIso = new Date().toISOString().slice(0, 10); function initialClientNameValue() { - return initialSession?.client_name ?? options.clients[0] ?? ''; + return initialSession?.client_name ?? ''; } function initialProductIdValue() { @@ -54,6 +54,7 @@ let notes = $state(initialNotesValue()); let preview = $state(initialPreviewValue()); let formError = $state(''); + let formHint = $state('Select a mix date and prepared by name, then choose a client to unlock products.'); let previewLoading = $state(false); let saveLoading = $state(false); let previewModalOpen = $state(false); @@ -84,18 +85,7 @@ const selectedProduct = $derived(filteredProducts.find((product) => product.product_id === productId) ?? null); $effect(() => { - if (!clientName && availableClients.length) { - clientName = availableClients[0]; - } - }); - - $effect(() => { - if (filteredProducts.length && !filteredProducts.some((product) => product.product_id === productId)) { - productId = filteredProducts[0].product_id; - return; - } - - if (!filteredProducts.length) { + if (!filteredProducts.some((product) => product.product_id === productId)) { productId = 0; } }); @@ -116,26 +106,32 @@ function buildPayload(): MixCalculatorCreateInput | null { formError = ''; + formHint = ''; const numericBatchSize = Number(batchSizeKg); if (!mixDate) { formError = 'Select a mix date.'; - return null; - } - if (!clientName) { - formError = 'Select a client.'; - return null; - } - if (!productId) { - formError = 'Select a product.'; - return null; - } - if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) { - formError = 'Enter a batch size greater than zero.'; + formHint = 'Choose the production date before calculating the mix.'; return null; } if (!preparedByName.trim()) { formError = 'Enter the prepared by name.'; + formHint = 'Record the operator or staff member responsible for this mix.'; + return null; + } + if (!clientName) { + formError = 'Select a client to unlock matching products.'; + formHint = 'Products stay disabled until a client is selected.'; + return null; + } + if (!productId) { + formError = 'Select a product.'; + formHint = 'Pick one of the products available for the selected client.'; + return null; + } + if (!Number.isFinite(numericBatchSize) || numericBatchSize <= 0) { + formError = 'Enter a batch size greater than zero.'; + formHint = 'Batch size must be a positive number before the mix can be calculated.'; return null; } @@ -172,7 +168,7 @@ } function clearForm() { - clientName = options.clients[0] ?? ''; + clientName = ''; productId = 0; mixDate = todayIso; batchSizeKg = ''; @@ -180,8 +176,28 @@ notes = ''; preview = null; formError = ''; + formHint = 'Select a mix date and prepared by name, then choose a client to unlock products.'; } + $effect(() => { + if (!clientName) { + formHint = 'Select a client to unlock the product list.'; + return; + } + + if (!filteredProducts.length) { + formHint = `No products are available for ${clientName}.`; + return; + } + + if (!productId) { + formHint = 'Select a product for the chosen client.'; + return; + } + + formHint = `Ready to calculate ${selectedProduct?.product_name ?? 'the selected product'}.`; + }); + function printPreview() { if (typeof window !== 'undefined') { window.print(); @@ -287,15 +303,24 @@

{formError}

{/if} + {#if !formError && formHint} +

{formHint}

+ {/if} +
+ + -
-
- -
+ {#if canUseWorkspaceSearch} +
+ +
+ {:else} +
+ {/if}
- + {#if canOpenSettings} + + {/if} {#if session} {:else if !sessionHydrated} diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts index 455e2af..ad2fe21 100644 --- a/frontend/src/lib/session.ts +++ b/frontend/src/lib/session.ts @@ -5,7 +5,7 @@ export type AppSession = { name: string; email: string; role: string; - token: string; + token?: string | null; tenant_id?: string | null; client_role?: string | null; user_id?: number | null; @@ -59,15 +59,16 @@ function createSessionStore(storageKey: string) { return { subscribe: store.subscribe, set(session: AppSession) { + const storedSession = { ...session, token: null }; if (browser) { - localStorage.setItem(storageKey, JSON.stringify(session)); + localStorage.setItem(storageKey, JSON.stringify(storedSession)); } - store.set(session); + store.set(storedSession); }, clear() { if (browser) { localStorage.removeItem(storageKey); - // Drop any cached API responses keyed to the old session token. + // Drop any cached API responses keyed to the old session identity. // Imported lazily so this module stays free of api.ts side-effects. import('$lib/api').then(({ clearApiCache }) => clearApiCache()).catch(() => {}); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0a47641..dcaf0b6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -180,6 +180,7 @@ export type Product = { mix_name: string; sale_type: string; own_bag?: boolean; + visible?: boolean; unit_of_measure: string; items_per_pallet?: number; bagging_process?: string | null; @@ -334,7 +335,7 @@ export type LoginResponse = { name: string; email: string; role: string; - token: string; + token?: string | null; tenant_id?: string | null; client_role?: string | null; user_id?: number | null; diff --git a/frontend/src/lib/workspace-access.test.ts b/frontend/src/lib/workspace-access.test.ts new file mode 100644 index 0000000..42597a6 --- /dev/null +++ b/frontend/src/lib/workspace-access.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { canAccessRoute, getDefaultRouteForRole, getWorkspaceRole } from './workspace-access'; + +describe('workspace access policy', () => { + const operationsSession = { + role: 'internal', + role_name: 'Operations', + permissions: ['view_mix_calculator', 'use_mix_calculator', 'save_mix_calculator_session'], + name: 'Ops User', + email: 'ops@example.com', + token: 'token' + }; + + const adminSession = { + role: 'internal', + role_name: 'Admin', + permissions: ['view_dashboard', 'view_mix_calculator', 'use_mix_calculator'], + name: 'Admin User', + email: 'admin@example.com', + token: 'token' + }; + + it('classifies operations users and sends them to mix calculator by default', () => { + expect(getWorkspaceRole(operationsSession)).toBe('operations'); + expect(getDefaultRouteForRole(operationsSession)).toBe('/mix-calculator/new'); + }); + + it('prevents operations users from opening the dashboard route', () => { + expect(canAccessRoute(operationsSession, '/')).toBe(false); + expect(canAccessRoute(operationsSession, '/mix-calculator')).toBe(true); + }); + + it('keeps dashboard access for admins', () => { + expect(getWorkspaceRole(adminSession)).toBe('admin'); + expect(canAccessRoute(adminSession, '/')).toBe(true); + }); +}); diff --git a/frontend/src/lib/workspace-access.ts b/frontend/src/lib/workspace-access.ts new file mode 100644 index 0000000..5ebc680 --- /dev/null +++ b/frontend/src/lib/workspace-access.ts @@ -0,0 +1,194 @@ +import { featureFlags } from '$lib/features'; +import { hasModuleAccess, hasPermission, type AppSession } from '$lib/session'; + +export type WorkspaceRole = 'admin' | 'operations' | 'full' | 'client' | 'unknown'; + +type RouteAccessRule = { + path: string; + roles: WorkspaceRole[]; + matches: (pathname: string) => boolean; +}; + +function hasPathPrefix(pathname: string, prefix: string) { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +function canAccessWorkspaceArea( + session: AppSession | null | undefined, + moduleKey: string, + permissionKeys: string[], + minimumLevel: 'view' | 'edit' | 'manage' = 'view' +) { + if (!session) { + return false; + } + + if (session.role === 'internal') { + return permissionKeys.some((permissionKey) => hasPermission(session, permissionKey)); + } + + return hasModuleAccess(session, moduleKey, minimumLevel); +} + +export function getWorkspaceRole(session: AppSession | null | undefined): WorkspaceRole { + if (!session) { + return 'unknown'; + } + + if (session.role === 'admin') { + return 'admin'; + } + + if (session.role !== 'internal') { + return 'client'; + } + + if (session.role_name === 'Admin') { + return 'admin'; + } + + if (session.role_name === 'Operations') { + return 'operations'; + } + + if (session.role_name === 'Full Access') { + return 'full'; + } + + return 'unknown'; +} + +export function canOpenDashboard(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'dashboard', ['view_dashboard']); +} + +export function canOpenRawMaterials(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'raw_materials', ['view_raw_materials', 'edit_raw_materials']); +} + +export function canOpenMixMaster(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_master', ['view_mixes', 'edit_mixes']); +} + +export function canCreateMixWorksheet(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_master', ['edit_mixes'], 'edit'); +} + +export function canOpenMixCalculator(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_calculator', ['view_mix_calculator', 'use_mix_calculator']); +} + +export function canCreateMixSession(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'mix_calculator', ['use_mix_calculator', 'save_mix_calculator_session'], 'edit'); +} + +export function canOpenProducts(session: AppSession | null | undefined) { + return canAccessWorkspaceArea(session, 'products', ['view_products', 'edit_products']); +} + +export function canOpenScenarios(session: AppSession | null | undefined) { + return !!session && hasModuleAccess(session, 'scenarios'); +} + +export function canOpenReporting(session: AppSession | null | undefined) { + return canOpenProducts(session); +} + +export function canOpenSettings(session: AppSession | null | undefined) { + if (!session) { + return false; + } + + return session.role === 'internal' + ? hasPermission(session, 'view_settings') || hasPermission(session, 'edit_settings') + : true; +} + +export function canOpenClientAccess(session: AppSession | null | undefined) { + return !!session && hasModuleAccess(session, 'client_access', 'manage'); +} + +export const routeAccessRules: RouteAccessRule[] = [ + { path: '/', roles: ['admin', 'full', 'client'], matches: (pathname) => pathname === '/' }, + { + path: '/mix-calculator', + roles: ['admin', 'operations', 'full', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/mix-calculator') + }, + { + path: '/raw-materials', + roles: ['admin', 'full', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/raw-materials') + }, + { path: '/mixes', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/mixes') }, + { path: '/products', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/products') }, + { path: '/reporting', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/reporting') }, + { path: '/scenarios', roles: ['admin', 'full', 'client'], matches: (pathname) => hasPathPrefix(pathname, '/scenarios') }, + { + path: '/settings', + roles: ['admin', 'full', 'operations', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/settings') + }, + { + path: '/client-access', + roles: ['admin', 'client'], + matches: (pathname) => hasPathPrefix(pathname, '/client-access') + } +]; + +export function getDefaultRouteForRole(session: AppSession | null | undefined) { + const role = getWorkspaceRole(session); + + if (role === 'operations') { + return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'; + } + + if (role === 'admin' || role === 'full' || role === 'client') { + if (canOpenDashboard(session)) return '/'; + if (canOpenMixCalculator(session)) return featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'; + if (canOpenRawMaterials(session)) return '/raw-materials'; + if (canOpenMixMaster(session)) return '/mixes'; + if (canOpenProducts(session)) return '/products'; + if (canOpenScenarios(session)) return '/scenarios'; + if (canOpenSettings(session)) return '/settings'; + } + + return '/'; +} + +export function canAccessRoute(session: AppSession | null | undefined, pathname: string) { + const rule = routeAccessRules.find((candidate) => candidate.matches(pathname)); + if (!rule) { + return true; + } + + const role = getWorkspaceRole(session); + if (!rule.roles.includes(role)) { + return false; + } + + if (pathname === '/') return canOpenDashboard(session); + if (pathname.startsWith('/mix-calculator')) return canOpenMixCalculator(session); + if (pathname.startsWith('/raw-materials')) return canOpenRawMaterials(session); + if (pathname.startsWith('/mixes')) return canOpenMixMaster(session); + if (pathname.startsWith('/products')) return canOpenProducts(session); + if (pathname.startsWith('/scenarios')) return canOpenScenarios(session); + if (pathname.startsWith('/reporting')) return canOpenReporting(session); + if (pathname.startsWith('/settings')) return canOpenSettings(session); + if (pathname.startsWith('/client-access')) return canOpenClientAccess(session); + return true; +} + +export function canUseWorkspaceSearch(session: AppSession | null | undefined) { + return ( + canOpenDashboard(session) || + canOpenRawMaterials(session) || + canOpenMixMaster(session) || + canOpenMixCalculator(session) || + canOpenProducts(session) || + canOpenScenarios(session) + ); +} + +export const getWorkspaceHomeHref = getDefaultRouteForRole; +export const isWorkspaceRouteAllowed = canAccessRoute; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..cce514e --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,244 @@ + + + + {title} | Hunter Premium Produce + + +
+
+ + +
+
+ +
+

Workspace Error

+ Hunter Premium Produce +
+
+ {status} +
+ +
+

Route Response

+

{title}

+

{detail}

+
+ + +
+
+ + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b5f3dbc..d4f8c65 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,10 +1,13 @@