From 5536acd67d1380ffa39801f588c65d31159ccbc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aerilyn=20Dziedzel=C4=97?= Date: Sat, 18 Apr 2026 12:36:29 +0900 Subject: [PATCH] Implement stores and refills, improve testing --- .claude/settings.local.json | 12 +- .gitignore | 1 + CLAUDE.md | 2 +- docs/design/MeshiTrack.html | 144 ++ docs/design/screenshots/01-check-org2.png | Bin 0 -> 30900 bytes docs/design/screenshots/01-check-org3.png | Bin 0 -> 29782 bytes docs/design/screenshots/01-dashboard.png | Bin 0 -> 30432 bytes docs/design/screenshots/02-cabinet.png | Bin 0 -> 31198 bytes docs/design/screenshots/02-check-org2.png | Bin 0 -> 29782 bytes docs/design/screenshots/02-check-org3.png | Bin 0 -> 29782 bytes docs/design/screenshots/03-alt.png | Bin 0 -> 29776 bytes docs/design/screenshots/check-cab.png | Bin 0 -> 32545 bytes docs/design/screenshots/check-org.png | Bin 0 -> 31155 bytes docs/design/screenshots/check-sched3.png | Bin 0 -> 31475 bytes docs/design/screenshots/check-sched4.png | Bin 0 -> 34021 bytes docs/design/screenshots/check-schedule.png | Bin 0 -> 30252 bytes docs/design/screenshots/check-schedule2.png | Bin 0 -> 31560 bytes docs/design/src/Cabinet.jsx | 355 ++++ docs/design/src/Dashboard.jsx | 251 +++ docs/design/src/Icon.jsx | 44 + docs/design/src/OtherPages.jsx | 518 ++++++ docs/design/src/Schedule.jsx | 162 ++ docs/design/src/Shell.jsx | 110 ++ docs/design/src/data.jsx | 129 ++ docs/design/styles/cabinet.css | 473 ++++++ docs/design/styles/dashboard.css | 152 ++ docs/design/styles/other.css | 199 +++ docs/design/styles/schedule.css | 181 +++ docs/design/styles/shell.css | 370 +++++ docs/design/styles/tokens.css | 123 ++ docs/instructions/testing.md | 2 +- .../phase-4-pharmacies-prices-refills.md | 333 ++-- package-lock.json | 1433 ++++++++++++++++- packages/api/package.json | 3 +- packages/api/src/main.ts | 8 + .../medicine-prices.repository.test.ts | 246 +++ .../medicine-prices.repository.ts | 203 +++ .../medicine-prices.routes.test.ts | 338 ++++ .../medicine-prices/medicine-prices.routes.ts | 168 ++ .../medicine-prices.service.test.ts | 164 ++ .../medicine-prices.service.ts | 93 ++ .../medicine-products.routes.test.ts | 14 + .../medicines/medicines.service.test.ts | 24 + .../purchases/purchases.repository.test.ts | 247 +++ .../modules/purchases/purchases.repository.ts | 142 ++ .../purchases/purchases.routes.test.ts | 476 ++++++ .../src/modules/purchases/purchases.routes.ts | 214 +++ .../purchases/purchases.service.test.ts | 418 +++++ .../modules/purchases/purchases.service.ts | 272 ++++ .../refills/refills.repository.test.ts | 203 +++ .../src/modules/refills/refills.repository.ts | 104 ++ .../modules/refills/refills.routes.test.ts | 506 ++++++ .../api/src/modules/refills/refills.routes.ts | 279 ++++ .../modules/refills/refills.service.test.ts | 400 +++++ .../src/modules/refills/refills.service.ts | 285 ++++ .../src/modules/regimens/regimens.service.ts | 2 + .../modules/stores/stores.repository.test.ts | 171 ++ .../src/modules/stores/stores.repository.ts | 62 + .../src/modules/stores/stores.routes.test.ts | 319 ++++ .../api/src/modules/stores/stores.routes.ts | 158 ++ .../src/modules/stores/stores.service.test.ts | 109 ++ .../api/src/modules/stores/stores.service.ts | 43 + .../api/src/schemas/medicine-price.schema.ts | 32 + packages/api/src/schemas/purchase.schema.ts | 67 + .../api/src/schemas/refill-list.schema.ts | 46 + packages/api/src/schemas/store.schema.ts | 32 + .../api/src/scripts/migrate-dosage-units.ts | 107 ++ packages/shared/src/enums/index.ts | 2 + .../shared/src/enums/purchase.enums.test.ts | 15 + packages/shared/src/enums/purchase.enums.ts | 4 + .../shared/src/enums/refill.enums.test.ts | 17 + packages/shared/src/enums/refill.enums.ts | 6 + packages/shared/src/types/index.ts | 4 + packages/shared/src/types/medicine-price.ts | 56 + packages/shared/src/types/purchase.ts | 30 + packages/shared/src/types/refill.ts | 57 + packages/shared/src/types/store.ts | 17 + packages/shared/src/validation/index.ts | 4 + .../validation/medicine-price.schemas.test.ts | 79 + .../src/validation/medicine-price.schemas.ts | 98 ++ .../src/validation/purchase.schemas.test.ts | 120 ++ .../shared/src/validation/purchase.schemas.ts | 74 + .../src/validation/refill.schemas.test.ts | 112 ++ .../shared/src/validation/refill.schemas.ts | 115 ++ .../src/validation/store.schemas.test.ts | 81 + .../shared/src/validation/store.schemas.ts | 60 + packages/web/package.json | 14 +- .../app/(dashboard)/__tests__/layout.test.tsx | 24 + .../(dashboard)/__tests__/loading.test.tsx | 11 + .../dashboard/__tests__/page.test.tsx | 28 + .../medicine-prices/__tests__/page.test.tsx | 333 ++++ .../app/(dashboard)/medicine-prices/page.tsx | 618 +++++++ .../app/(dashboard)/medicines/ActivityTab.tsx | 7 +- .../app/(dashboard)/medicines/CabinetTab.tsx | 8 +- .../app/(dashboard)/medicines/LibraryTab.tsx | 4 +- .../medicines/[id]/__tests__/page.test.tsx | 777 +++++++++ .../app/(dashboard)/medicines/[id]/page.tsx | 16 +- .../medicines/__tests__/ActivityTab.test.tsx | 298 ++++ .../medicines/__tests__/CabinetTab.test.tsx | 667 ++++++++ .../medicines/__tests__/LibraryTab.test.tsx | 220 +++ .../medicines/__tests__/OrganizerTab.test.tsx | 514 ++++++ .../medicines/__tests__/RegimensTab.test.tsx | 530 ++++++ .../medicines/__tests__/page.test.tsx | 49 + .../activity/__tests__/page.test.tsx | 35 + .../medicines/cabinet/__tests__/page.test.tsx | 35 + .../medicines/library/__tests__/page.test.tsx | 35 + .../organizer/__tests__/page.test.tsx | 35 + .../src/app/(dashboard)/medicines/page.tsx | 28 +- .../regimens/__tests__/page.test.tsx | 35 + .../purchases/__tests__/page.test.tsx | 543 +++++++ .../src/app/(dashboard)/purchases/page.tsx | 708 ++++++++ .../refills/__tests__/page.test.tsx | 681 ++++++++ .../web/src/app/(dashboard)/refills/page.tsx | 708 ++++++++ .../settings/__tests__/page.test.tsx | 271 ++++ .../web/src/app/(dashboard)/settings/page.tsx | 4 +- .../stores/__tests__/page.test.tsx | 424 +++++ .../web/src/app/(dashboard)/stores/page.tsx | 522 ++++++ packages/web/src/app/__tests__/page.test.tsx | 22 + packages/web/src/lib/__tests__/useApi.test.ts | 137 ++ .../src/services/__tests__/api-client.test.ts | 165 ++ .../services/__tests__/cabinet-events.test.ts | 62 + .../src/services/__tests__/cabinet.test.ts | 91 ++ .../src/services/__tests__/households.test.ts | 47 + .../__tests__/medicine-prices.test.ts | 62 + .../src/services/__tests__/medicines.test.ts | 94 ++ .../src/services/__tests__/organizer.test.ts | 62 + .../src/services/__tests__/purchases.test.ts | 62 + .../src/services/__tests__/refills.test.ts | 80 + .../src/services/__tests__/regimens.test.ts | 68 + .../web/src/services/__tests__/stores.test.ts | 56 + packages/web/src/services/medicine-prices.ts | 60 + packages/web/src/services/purchases.ts | 69 + packages/web/src/services/refills.ts | 96 ++ packages/web/src/services/stores.ts | 51 + packages/web/src/test-utils.tsx | 20 + packages/web/vitest.config.ts | 48 + packages/web/vitest.setup.ts | 5 + 137 files changed, 21218 insertions(+), 221 deletions(-) create mode 100644 docs/design/MeshiTrack.html create mode 100644 docs/design/screenshots/01-check-org2.png create mode 100644 docs/design/screenshots/01-check-org3.png create mode 100644 docs/design/screenshots/01-dashboard.png create mode 100644 docs/design/screenshots/02-cabinet.png create mode 100644 docs/design/screenshots/02-check-org2.png create mode 100644 docs/design/screenshots/02-check-org3.png create mode 100644 docs/design/screenshots/03-alt.png create mode 100644 docs/design/screenshots/check-cab.png create mode 100644 docs/design/screenshots/check-org.png create mode 100644 docs/design/screenshots/check-sched3.png create mode 100644 docs/design/screenshots/check-sched4.png create mode 100644 docs/design/screenshots/check-schedule.png create mode 100644 docs/design/screenshots/check-schedule2.png create mode 100644 docs/design/src/Cabinet.jsx create mode 100644 docs/design/src/Dashboard.jsx create mode 100644 docs/design/src/Icon.jsx create mode 100644 docs/design/src/OtherPages.jsx create mode 100644 docs/design/src/Schedule.jsx create mode 100644 docs/design/src/Shell.jsx create mode 100644 docs/design/src/data.jsx create mode 100644 docs/design/styles/cabinet.css create mode 100644 docs/design/styles/dashboard.css create mode 100644 docs/design/styles/other.css create mode 100644 docs/design/styles/schedule.css create mode 100644 docs/design/styles/shell.css create mode 100644 docs/design/styles/tokens.css create mode 100644 packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts create mode 100644 packages/api/src/modules/medicine-prices/medicine-prices.repository.ts create mode 100644 packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts create mode 100644 packages/api/src/modules/medicine-prices/medicine-prices.routes.ts create mode 100644 packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts create mode 100644 packages/api/src/modules/medicine-prices/medicine-prices.service.ts create mode 100644 packages/api/src/modules/purchases/purchases.repository.test.ts create mode 100644 packages/api/src/modules/purchases/purchases.repository.ts create mode 100644 packages/api/src/modules/purchases/purchases.routes.test.ts create mode 100644 packages/api/src/modules/purchases/purchases.routes.ts create mode 100644 packages/api/src/modules/purchases/purchases.service.test.ts create mode 100644 packages/api/src/modules/purchases/purchases.service.ts create mode 100644 packages/api/src/modules/refills/refills.repository.test.ts create mode 100644 packages/api/src/modules/refills/refills.repository.ts create mode 100644 packages/api/src/modules/refills/refills.routes.test.ts create mode 100644 packages/api/src/modules/refills/refills.routes.ts create mode 100644 packages/api/src/modules/refills/refills.service.test.ts create mode 100644 packages/api/src/modules/refills/refills.service.ts create mode 100644 packages/api/src/modules/stores/stores.repository.test.ts create mode 100644 packages/api/src/modules/stores/stores.repository.ts create mode 100644 packages/api/src/modules/stores/stores.routes.test.ts create mode 100644 packages/api/src/modules/stores/stores.routes.ts create mode 100644 packages/api/src/modules/stores/stores.service.test.ts create mode 100644 packages/api/src/modules/stores/stores.service.ts create mode 100644 packages/api/src/schemas/medicine-price.schema.ts create mode 100644 packages/api/src/schemas/purchase.schema.ts create mode 100644 packages/api/src/schemas/refill-list.schema.ts create mode 100644 packages/api/src/schemas/store.schema.ts create mode 100644 packages/api/src/scripts/migrate-dosage-units.ts create mode 100644 packages/shared/src/enums/purchase.enums.test.ts create mode 100644 packages/shared/src/enums/purchase.enums.ts create mode 100644 packages/shared/src/enums/refill.enums.test.ts create mode 100644 packages/shared/src/enums/refill.enums.ts create mode 100644 packages/shared/src/types/medicine-price.ts create mode 100644 packages/shared/src/types/purchase.ts create mode 100644 packages/shared/src/types/refill.ts create mode 100644 packages/shared/src/types/store.ts create mode 100644 packages/shared/src/validation/medicine-price.schemas.test.ts create mode 100644 packages/shared/src/validation/medicine-price.schemas.ts create mode 100644 packages/shared/src/validation/purchase.schemas.test.ts create mode 100644 packages/shared/src/validation/purchase.schemas.ts create mode 100644 packages/shared/src/validation/refill.schemas.test.ts create mode 100644 packages/shared/src/validation/refill.schemas.ts create mode 100644 packages/shared/src/validation/store.schemas.test.ts create mode 100644 packages/shared/src/validation/store.schemas.ts create mode 100644 packages/web/src/app/(dashboard)/__tests__/layout.test.tsx create mode 100644 packages/web/src/app/(dashboard)/__tests__/loading.test.tsx create mode 100644 packages/web/src/app/(dashboard)/dashboard/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicine-prices/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicine-prices/page.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/[id]/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/__tests__/ActivityTab.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/__tests__/CabinetTab.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/__tests__/LibraryTab.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/__tests__/OrganizerTab.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/__tests__/RegimensTab.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/activity/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/cabinet/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/library/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/organizer/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/medicines/regimens/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/purchases/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/purchases/page.tsx create mode 100644 packages/web/src/app/(dashboard)/refills/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/refills/page.tsx create mode 100644 packages/web/src/app/(dashboard)/settings/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/stores/__tests__/page.test.tsx create mode 100644 packages/web/src/app/(dashboard)/stores/page.tsx create mode 100644 packages/web/src/app/__tests__/page.test.tsx create mode 100644 packages/web/src/lib/__tests__/useApi.test.ts create mode 100644 packages/web/src/services/__tests__/api-client.test.ts create mode 100644 packages/web/src/services/__tests__/cabinet-events.test.ts create mode 100644 packages/web/src/services/__tests__/cabinet.test.ts create mode 100644 packages/web/src/services/__tests__/households.test.ts create mode 100644 packages/web/src/services/__tests__/medicine-prices.test.ts create mode 100644 packages/web/src/services/__tests__/medicines.test.ts create mode 100644 packages/web/src/services/__tests__/organizer.test.ts create mode 100644 packages/web/src/services/__tests__/purchases.test.ts create mode 100644 packages/web/src/services/__tests__/refills.test.ts create mode 100644 packages/web/src/services/__tests__/regimens.test.ts create mode 100644 packages/web/src/services/__tests__/stores.test.ts create mode 100644 packages/web/src/services/medicine-prices.ts create mode 100644 packages/web/src/services/purchases.ts create mode 100644 packages/web/src/services/refills.ts create mode 100644 packages/web/src/services/stores.ts create mode 100644 packages/web/src/test-utils.tsx create mode 100644 packages/web/vitest.config.ts create mode 100644 packages/web/vitest.setup.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 78484fa..71c6cd1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,9 +1,13 @@ { "permissions": { - "allow": [ - "Bash(npm run:*)", - "Bash(ls:*)", - "Bash(npm test:*)" + "allow": ["Bash(npm run *)", "Bash(npm test *)", "Read(*), Glob(*)"], + "deny": [ + "Bash(grep *)", + "Bash(ls *)", + "Bash(cat *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(sed *)" ] } } diff --git a/.gitignore b/.gitignore index 41687e0..3eb5b20 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist coverage .nyc_output mongo-data +keyfile # Compiled output that may land in source dirs packages/*/src/**/*.js diff --git a/CLAUDE.md b/CLAUDE.md index 5a69891..39d6a70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ The API follows a **routes → services → repositories** pattern with Awilix c Plugin registration order in `main.ts`: security → compression → swagger → DI container → database → auth → household guard → route modules. ### Domain Modules -**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `refills/` +**Medicine domain** (Phases 1-4): `medicines/`, `medicine-products/`, `cabinet/`, `regimens/`, `organizer/`, `medicine-prices/`, `purchases/`, `refills/` **Food domain** (Phases 5-9): `products/`, `recipes/`, `pantry/`, `meal-plans/`, `grocery/` **Shared**: `health/`, `users/`, `households/`, `stores/`, `llm/` diff --git a/docs/design/MeshiTrack.html b/docs/design/MeshiTrack.html new file mode 100644 index 0000000..11e8799 --- /dev/null +++ b/docs/design/MeshiTrack.html @@ -0,0 +1,144 @@ + + + + +MeshiTrack + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/docs/design/screenshots/01-check-org2.png b/docs/design/screenshots/01-check-org2.png new file mode 100644 index 0000000000000000000000000000000000000000..c7c15690effe940036a0ad84f85299b8f3aa7cae GIT binary patch literal 30900 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

%XrB4021A-#<;B3k7GaIGl9)_*U;Z3CiSw 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�aOdl# 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 literal 0 HcmV?d00001 diff --git a/docs/design/screenshots/01-check-org3.png b/docs/design/screenshots/01-check-org3.png new file mode 100644 index 0000000000000000000000000000000000000000..0f809adda26cee63ae2ddf852d217ed669c9307f GIT binary patch literal 29782 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 op<gMQgT#4$sk!Wh>`>Z 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 literal 0 HcmV?d00001 diff --git a/docs/design/screenshots/02-check-org2.png b/docs/design/screenshots/02-check-org2.png new file mode 100644 index 0000000000000000000000000000000000000000..0f809adda26cee63ae2ddf852d217ed669c9307f GIT binary patch literal 29782 zcmeFZ2Ury8wl-Q#YD9uyqqHmKA|ul@$=;M5!xiX{suqRdmfg zY;=vBjaATg53W1k@e2wLx@;Yhg!fPM2nf1M6ary9eVX+QtMu8k(svEe26z9YzEjsJ;@NFGl4A(hs z9$r4N3*r|gB&9AXtDsfY)HMtYjf_o9&CG4>uHUeCaCGvx?df&L+s8NLUTE0;2jRHb zxcG#`q~w&;tn8fJy!?W~qKe9@>Sr~zbMt4Ywzgn9~gW;G(7TQ^y8B-2?icl~p z>OrqzPI91vC}B!5ndME#kfQqE7;W79s7`T;edPL1H0{W;zh+qQf6KDp4EwuXqX0E2 z1iX2q^ng6@Wibl}@ccKon+L7CXRga0&iK=yqRC-;3jZ56hCUToqmlw*;BM1b?dbiH zjiMTpGBd1woXctm{9KT@*wuxasCaz-l-$KouN2xZ7EL)nnQ0odenJ8%gFkcM^kp4? zo(^0j>G|B07!e%j(nYFeOqRIW4rjn z1xbMhl{@olW7^M?SLTvKQ49^V8LT>&Y zDu!&1doqt?rbHuNy7aa&d+#p|#frk|XT7c?=VE~C`>JG^QLI3pTD~N-#y4`+*40sF z+C`+b2U<$uKrhIc7KL$@O^%)I5jti}Zb&YzdEHG4GbO8HQ2vTwQpM&#P`~aq#r&F4 zoO(8FR0=b`nL#tk&+_%$sn*R^g2=qEX|qK(RY_EGDCcdNSr7dy%StpFJPkkk#YQ>N zNRL%c>N_(b8)A?`6C?57Qu$qPeuTl?M4|%xE4C9?S>#r|{Bm#KtAO8A zwr4^2epWa$XnZ}iD|SjuQ)V4q?h@DY;4GROe>c0Jch3CwzueM4y3PLs#~^|*JuS+s zUsLC+3$84f;$X|2z}D=rCim2ZhsCB7>TWfsQUoh)0XNK{HV1dPhBM!&+J`Tzmtv3M zM7JNgPW}YwP40^6jLfmzOMho<=zAj@_K?%<`SY}3Cd}^8R*XH@kLdI%AHR~DppCqwIAM&xs$KX(ZZ~@K$Xq8OLe35>J~=_CSOOUsNamHPI;^$ zkj?`i7;)+J{i)uUeh+hE&ZD`E?iI2=x;zuh?tlDcA zh#i?iJ{aVPb8TY9i}oTgkq3STfiyKqByFCE8~kw|L3D5H@xVnliKWyJ7j++EFTM~h1O{g@Qc@b=^TRbK5Iu;L+YsQ}edhRPHj((b) zA6;Sao<5|VjMh^c4)CcRa#+92gpx@w=gWT``!5OzNXf31RY0VzxF(x<@qe_&NHauWb1a}S{s1va{T-1 zPz0)wi8H8n1v*F@oR@_?tEr~v#`s{IEr~m3D(;fG!laAAfy?pfn6%r41sRk9Z%?(N zCp$;l9pSG82)y5wqE|;~EedZ4=zMbA&;=+c$jL}3k?flkI3ALQQ>V68S;ZvvLdU-} zL9>Fr_jy$kYHj-3{p5k6gdf zYlM+S(%F9gF&O{|#1nfQ-d#_j zBpv*epB0o+Icjj;|leiC$ebO!*L#KGZIXCy{}%})YE9R ze`xlq;$sN8oqkFaoXJ|JTCh8tvO{;61{oz>tM7&NB@Nv_a5{iBwpz&ra?d}2GjJJ7 zB2LVHUuxx3u1eviX5SfXH`jkdm$vdX-PP%RRsyznz}Ed``6KcE@VMQ5XHlN z)0^GFTwX|fu0vmf^Lt3@KvptS-_kmz*_n&uO3=6G5Mg8l3LRkWvQNoz9&@q;s?e(! zaFBQBYjVwqG4PMKGd}^$%dixkz}l%xS~(CEm@dR}fBh?80(Kq?Z)dO?oK5 z8MV7BKNLk3*cRG*;4iadrzU}6=fE)7rZeRP7KYge)D0*@DDJ@a{oHA z^qz`+v$csOp`XBVVqp6E)kT`y@sO1KWqsL=QO`xA=l5Kw8w8##Lv2^gJ-a8*r_A^% z-23iww+j==wy&a^yX58sHNef~I?Oa!hjd9YeI+cgRzU{!EI0AGS027|oovwSfZk(XaY zBiA`ZnSl+fOOW?dni?Mnxdh$X#k>RB>ag1#s1~{JP~i zK}aeRSbgF;R~4-a>(Wvt!KBejVGI^i?dWdiPf4o{A3M=Bi3ygn!HWNR3J~YE$@Ul! zggr_c47lxbRd44VNuPS1zv=>d?tj;bkIV8>fcc-iS--w=y_da~@xOBqa+I2-EoL&$vB>gPVTp+zKb60{ew@`qeWNN8&b8(n#6R)SYo zP;h{uY}8-wt45q+dBZ?vBJz0 zZGB2*n8~2xK&b01!ep)J3 zsf+w%9KYkzTqs+Izzt{M@vQNXu1C&FYd)*ap9Y><#~RGZb}}eM3d=q}%zvSSq1k$J z9g}aR%2>5X-!$V}E}+x-u3s3Ylx8N|O(du(F}QpVjd zNW{(Sg^EQJlMP;YAtQ8?=-qlWRN6_JMc$~Qq9L3nBh}}OaS|v>K?(4Ta zTqM~d>>$cRvMhq>pVGEr@aV*2gmf}KyjqjmHdt+uuCN6)U|?M1<8nog#ZW-4dA2~J zP2WdWv~5P=jG-~+z4b}as=kNUANq8bx!rMIh=ktq?AEv-eTn&1awn1-4ZQN*QZ8`q_EE(N0$w=$03HuhVNk#0>~<8hVLS4q66 zFG>)Jq+?k!F)R14=kl67rlqvt&EoaFM|vg~xSCb?vhc=3W_U#fcQRGB`02ei(x6q0 za%yglhnP0+cdJ?FEwKp%yYNd3=ys`eu?>s(9AHI{l2eJ)XMQ|ZtN(Qxo7?ZHD01MULt8; zJq{$@j9N~KVd5Ahqw$9r=DXc#*SvS4pm|QsCsi>gV$XFVHpy;FAffqUz*-YpJ#XjX zav)MWsp#;6@y%Lm|5$NmIP3nWgjUP-(}RcTv|{GWqG0b4EOQ*5noVo4&mRnt53$?2 z6soLMqeN1`fagSpi^LbHfoJEpRX77%HV@;+!l@lH1J=v*RtOuwS^XOm`ZZf* zf6+L4F^GEgGw+C|EuY#n9&&l$Rs@~`c{VO>wr}tW|CQM{F{=8&vIEOuUP8c+-808n z%Kh*M4oBa3&I$n$v4}LDUKS^Qv)P0^bv?ZqYw-{QC0Ss)leLzfAGJU4<73c*LMQRP zg+*QQuvD-6wNq}y_uVZGM&=vDr=pTM0>vBtQS!g_K9k9F}6 zCS%zsxld8gGW|0xd2NEVXmVf325Q(kF1wsjuze$J`<%Utu3T20PkA8WoKT)@{oC~u z=X;$eQ}!K!6}iQ6rIhh6Cr;m`wjcPc;>Y~CH<%Q6O5$YL^c-0dRNF|89FOkS(vgD z5YxW)JMSNyUAsH=$ZO{~_?3IFG1RFp@ad%7L_w2b9#t&m!tm2-fNw&>lukI>F2YAw zft}C}a<*_<^&funaV1w$_ zPAnVd_Ol&9L@4ZfG9+jxeNLV1t6+EYtw+Y<0NWMO&w5r%KLN*>k>?SMr{}d>FogRu zo=@nNYzbx;NjRI3ER;-nq^H)zf)JK(QXL#Umu7YO??Kal8B|y^YhLy*UJ-!o}jX$OP3Yty;{m= zdgG)Evl*NgZc!i~YU0P8NDr%|Fe0^H`_aDYv#?QE;(XXsET>i8+rgwDPi3tQCKK#` z5IG=A+ou1*i`1+m!@dxTk&+bi$m7G0P z@fBmLN`^602476$Qty>&_t6g@8yZq@36I~$(`Hjaja?exc4L zGEJCpWbb!ze%)SjQWNo7MZcM!PT(xrOyojlNf8g}nRV@k22q&^l7?u?MtwIq{|QVl z%TGYVb2UM*rE8tPVEnrj&6hgqEpu#px3{;_Gqz0zQuJz6<%6y}u4eI!(YJkA+ePUS_ep&K>3BMIndjbPv<6>6ZIJ{E8KGTVRU(` zojSg>H!8a2kfYiplZa%R8Lf86p!FA%evlcV7>O4D1cwmR2ICP-FOqr@r%q?Y7h1`OB{#r~-tH@~XVRw4B$h50lO?CXPpQPc!$s`Kj}swVYd5 zzLy5Li^fD9R+~NidIR2_J9cLJQVEdAAJIyWgP0Jz0}dw^e*zCbAMi;(IAaSp zqhcC!n~{`g+sqHCS}6Z~dGggH45Dtd+A8;M8c8uPU>%aaWU^F0y!U23M@asDoEAmo zPrzV*$>eKPW)6Z%>V&GE8hP!*o}d{h`F-fSt)jToUesp^(s)~u>5xrsyuZ_;=PG71 z+xI8n9dNtx)^QG{hoWDj=~7;3(`@&?GkEyaHa%CgkaJV)2*eYCd?Jn~kK8Yh&z8D{ zLvX!JqAg#xu0XRkJV*6Lq$G+~ef&k+E8iC??4lFnD#z<%<|S>YLlwx}M8~S7&^Ok| z?7gKcPT7Y11W29?>RF36EZ`__Q1?a~C2_9#2B_lVZ%9A#o-`ILTP5%1UfOxz$x{a3 zikN4oP<#;tUk`WPo6kIXxg8-&^Wy>ahKuB(XNk%w2+-If_}3!hzeYb|1pI%rJyOls zFLcNt8+{qFmhH4l*O`a|w4W@3`P|e0T_>Uxcl7)IV=e87DB~%}kI6O~h1{^tmgS>( zo?ySmq|D4*!+?qU4kA`VDx0C&F%<;M5v= zYp*hKmr9%~)`h63%Dg4>8)vvkJ~RF)JkM0?T9BOWJ*Lf{00)-ym+*k^%KCV%X}r^v zb%g0Ba0ZC|19tZJjO`IqTX5*Aw__M}1b47Hj|Kw_?@nf~5CxeDPrqG}o-tE>=>y7}0n_>X5ZsCMKt_yw!w#X3?FunM9Et;VxSUp*ppu z91FXeglS`^D-rGHzv05kte`Z9iHoe3R*pESY)EFUsMki3)>)iuN|Iw8GOkMzI6B8_ z(}8eUJ`L2Jt+9_x>17GZiQIV%OK!ApZHnuds5-QAYA>q+un`?lIa~Y4v<2Bk3o&&Y z-q1+F7891+tW%L%74eJeB!KgZR*4iFP=&qgLyQBx(tO`gkwY_#x4C`ir!nH6U>XPwO;KBRcPEstQ#>%o|&^~ zzs=o6H`jW}ofb8_KOrH@?}SsZp=26sP>1knnO!@HR;TGP-1Fo}zwojHPDX8gnrDph z7!w%yS3vgXAtbgeTDL*SS!S3+_TIE@R8pxZVWLIkWBiyREIN^y&E-eeJ&!fKJup+Z zWFe+!jmijCl^#WLoq9n3*jJA3sjmf3g3CvTl}Q-|qYkCITTxwm+a(%2WnkBU*6%+W z|NQTNE%z7DcDWS@lsg*)59;ni{wwHi!W;-kd z^_l}uFc?k~hiv!kiIcrB4Ag>gXu(5Ss>gP8b~6kEdQBm8$|x zB-$z~f`%e!rF21V7Iegoh-{|DwmcqCX5&myn+9>3)W6AOP0l8bgu;lf@o1d?-M}6< z6i?%kpMIH~I8d^&8>D`d5#J;~qx%d}q4f=&L13m!0#>(=AQ=&k9aUwBm6`wIMtb$Al==0S5J?1gN6$8I{05P) z1D1KRlF1oiJthD~&(-xSThC)`wTcmguw;k4c?GS4Toty&r@-O^^*Edn^Z4!YibzZc z5M-c;>Sf(vfTZ31UcuIZy`b96wgydKPTyrJp77oISeK^C-^R(yEaU?B=utOq+_{g4 z_1@?7$VU==@O|zozK*MfqFEe_~Z^s z?b3e6M<%T4M@4pPyWtC5i)XZpb-RrE+xU!UTQANqLC|JlQYV89pcoH~TWmY4x*$q9 zBBU1oJuPsvfHXJQd)0esHFacTy|5WQ<1w~s)L))3%^@%(R)}4lUd?Y25 z?9P*aYLn6HmT?2@IPI|jXL_SSNTI^?oLW1wKj9s1)+5A2ftwtNC)6R!yn~H=L~4pp zBjV-GE)}4`lXcXP^$SSMQ%5NqO51|3#tYFE;ZK)d9hkjw^OofyapFT_n5^2o$)>L} za~_DOx!4?QC~sYPb$dZBIpbJC0l~WuS80v64+%;)7!AQN2^~Yeg}cUoS77MV^Uo4E z`3jpE-sWVK0yleU4YNzsmwybXtDq$Hbu|z(rHX7l>HiVM5@JFC!cjFN)3;(HjY%nN-eNlHl=-;z%|>i$WW!m zY14RXL2zSZbDx)*fGah-(%Y4=mU%}Mt+BDOZ~>vRa(g>ryFL`fc!L{Z4;O>De%GW?khni80;(qKv2Lixf$7S37W;`%C$d z;d`3rrrLsoB3&`OZl#%F3NugVehiumW{o=bMc^tlWIDw0G;IXJggjL}r=!>nNj)p( zqOx}RPzeAGPvAHGch4;2@#;o*B<|dmmK^p&3vF!`z@WbCDS0^)14F;{!W@h0CpAX* zIHy&bDi>0Cn?!B~nJma525uEO@sO~k9~8+E|H*4g84$QtumhHx3k$LuFRlLss#SXr z-)pO!`uiZBGJ2tdH&18u`&5LF>Tf;Y$@I0eu5j|1b=`#w?hZx3AmKS`HBv8Dy6d4e zBFaHY(WcZh-+Dh=-2TVr1la0?P5rY(^l>KI&UwUmDUJtz08_o}6Y z1=Yy$U`cUv~2SYZ2x@Mw6WO9+c>k78pU@7Gm1W zFH+kh*@&eYI*&y)bvMKjcy_busD&9gnFRlFkRi<-}Ru?M5kvhuxG%}dV< zaS9GuM6tZ*O4+Px%tV zdrX?0eSZXasBMUd10p368`(&S_Ff`JcoYWmK$d+mZAd~q2#M_M;qYv?R(FxlM7V^K znDyw#<7bc@;B`cqZKV$FGciO+l!923_}xHI!BmvwEQUD2uLD8<5)p)|Or%s_t*s=} z=FxTVRT-l?U6^`QVJ--FNg^sa zvW0jVjwq7$U(74{15cC^p&MvRuLf~;A{_fgMK~z)2ymh?F{woLSnH0ah*T8=eRG7C z{x%TaswzhGm`D4kBj5fa5i}Z2wD@-tK{JjfBialhvqQWi86=`6!vtpkbr9IZ>!}>| zeR5^tc(HoA6OwAjj7QzG^h2zjv)(QGXcoqDuwrUp0-~V%Eukuf@tlcIHtFhc4&hY(lg83 zKX`b4Ugdl|LQ>(*ZLl{NEcbW>1RLuJX+L4dzodBA1~!LmKwNN7wr0;`V;KeXu0|BU zDO>oqk#A}#+g+r|;cQEg_S}6(j{A3=1X>~QaN#|sdqx(1NwIfuYWG}MXg_m5GeQ^Y9t~-;%3`|xOZB`214rABRZ6_rRiKB z(0gi1I|MfNXi?BEQd0A4Gy^V1G!Abc3LP|9S+#kNZxE~{F$|{UQ`J?R1!D}w#0h>QFui1g2p>3?WH+x2BY za8AM4*L09O0-qvcH{dVLp)T(l$jB!;(|o5xbPIL6%%Rpgoidp%mSAgNl~Ocd;`kQf zYZs@l_K@>dB&wJaIWka?K~+Acm3P4}qOa8(;uW_PM+iJ?G^{cDVcjHR|H0vnak@## zS^Q7}3_=*a*+JW3Pi-tONU3FwiS?QN#%Em7*)G%jN+M}>qgCR74#(*O zp8y;3ot63lxewbUvf!%yZ!S7EVCk{_Z&O1fV5^aXv)H(gzxV>m~ zN)@`VW+mYC9Zj961*LGju@{b7JP5xuhp8PQT0%M|AUNgdVN#$4eG)|dMUjf{Ok&_m zxr7ukQ5_^N3PONFfbGxex%ym5G3#MyGT#v6rp}OMzI!4*>iAX8*)&`3C6l<-^ZjvW zL@&TUJ{!8dBgCZ^_cqzZi%LGEt_U^m{M>Ibm-ohYIYqoUef(K{j{z4q(E;4zVvcAs z&L~~Cx;EC{p=F~pV*Z3k4Ifl?&v(!(sb5oEe_3tF!ptlxZJ^>J8AC0^)PA*~v=j%- z&c)oQ@Ql^}-XccZ-{#$qMvX|=2Ftpsw?T0P{zr-LHL73dyt>IppYN_^g?%6-QuBE7 z#b=O3lzgb}k~nrbkvA{aCo|=N4?LvoYEm%9Xf7={cEs}Sg~oE*vqF|nT(l=Vs%B)o zHcDUOfc8EvAFjINQkuY673WvEp{CxRMn6oCY^C|eKcWjFKK++X1%qKMrjuDkhEteD zIVDy!XdLUi@%q%9I=j7f`_3yWAXV00C$V2Mr_O-dp0@df;;!ul*~cp{_E|lPv((nm z3P|VxMIDz1pQTmO&05=rV09to>oP5*F*RemL-W|zKGnwyg7#zv=20fhWs)GMzSZ{+ z-SbMH-7J&m!cEE?I^+o-6^>mP*L7K!X@uF8g+E|>z*HC?ru&Atioi%S`g-lNnp=$X zs%`$YZ69Oh`RYJxJ{YBP8#)&oVkhqp)v5M*GJMgp=n2^R-aGFouJy=@_8D$gzWf5+ z&h}5BZFt||uz65^Vd^KqcJ+Jioe{#Fkk<2}l= z-^QT46uLP}^~y9=to)(ogz?=^K4uTe1(bM4p`%(YMUgdMj%0SPXN#}UHyaQD7mj9%L1k=*Mi(Bu0%iXmSjlvbJXHP~Y91lFJQ{c)a zJgUiYF#YQMa5W%&)gpcz9@S;SYGIFj%GAp!)n39BV+uopD?^GWwR4Nz#9ASE=u&Th z4*UZ8VzY40mv<_&ERWQ!x(RamOcL8U6bCp3#b{f&S!q$epJ~TDC+WM+WGSU4B~`~JVsqU7skX;EZZVbY8yVz>t3X$ zH@HV+`@ND-T6zub=OV&P+u9QS*R;7>v(JpxzizCU-tSakFN3pY69yq(IP4Y0@^l{v zr9M@3BO!#To9$M}Te{lmXXDP&l}mVgF^DXqe*X^lBonqNtpHfdn33;HIIouzov26B zTtOk@+51D6yois-KGJ{EO!7)*d?8y<(aIh6S8I%hScS2W+F;RoIqY|Z(+&kfc@D}d z3et0ik{jpJ1v;og&LX)1K%IMp3M?vMCl!#YQwedaEBfl2)4ql=8gb|f^jJ(CbO_7# zC)~<^M7>$b^l#xVs^$+n19gUq`z}wLoG6#U8%|r>B;phd2elrORJp?-!xGExjh-8Y z=g-AbCS)lhPm$V7y5{FU{b+!Cf=7k1xS<^#Nb0Wp$)ijL()~#;BfVwMJ*A8F(9K)KICuv(;GHMcbp~?T_h@ z|DizrD~Iyu0eb=8!3tZkzQik+m1Tc}Py30_lXtV$0dnaJj5lyVBxM)oW29+pmug|Y zT5(@R_7@w8TlUbU%UM{SN9x>O6 zN6`7c%fgDT3UDTxS)%rPBC|1Ux2=O!bza}ANJPbE;58~vjaofm$Gf+2%l#;!;vp56 z=1pbGF>Q0kiy724I%QS*RprMh{hKF=|+5=bp z=cUsBga>kZB^JQiTk)tQrsO4{b=FHR;v^}BaLpuO`<{J?=$FJXuLFYM%SRNv5JKv` zTrFIk{o3Gp-D85W6snfKyN1t(PrmhIOICWC_(4Im-CDFsLjG-Pt@B6M7u5E1W1W$N zbPc00E zlIVqk-pdVpp!I*?7g^u$D`1`G+(-QSjvfKiODx))^WB^o1#=&wFR>?OX3{(Ho1fc= z+JISQ?;P5v?H6qj|1vD{Z_?1xhlWIzh;zn(%>oHJPS|-#w*cBI7mlOmh8;J0 z9BUYM#Md+<#!fsF#UCYbD$nC(I2`nhU0}tbWf%gQ+|(?~=gfr|_;67eS=4LrlW!8c zcJ^cyN`;@Nr;nZOsY}(m2T2_|RrpPTkmwN^nMcRpy`H_c9hs@^#rdIY$clt1p}wA(?YafI4akt8z79fP z1rZUJfXir_olc*n@z6CBsfnkYkD#V3EjNQPhma;xPcqHyAjlIc#fkR~8ni$%}$W2L0{ljANEOkK3+>b2ccor=n>+Pau`Syu+~tb{TS7;*TFi|Q1hO{v7l2rRV}iWoN@sj zhAzAV*kcQ_k#u5^d~o^apzRAEThlq5T5zH|(8FZMdW_NicUfS;Ty$&3gGr^!fa0MH z5eq%p+BmD!hE(f%JBPhUBDIhfP%Y-cS#~Uri?a8!1BnK2bZ6!3*n+cGm#8=ALBjoa zWTwI~71$A>P694{NT#3rNM`u;(yh(T!?$-pLDaSsk9 zP^=z&zJe2=K*V%N(sQ1*G-W=p+u?CV}heA&Sx;L50YW0d7Rp9vSONB3@mrCPGpWeirHbZ0Qy%JR9= z@wq%{#l{_t@`^6otW31ompw|7(u>I}aXtSpWT;sn`yepQ2ZIa9`T( z-YberL#2HRh`SY+JZ}bkoELY-Fkk3bS$~C?bpxP?pdV+3zkTV~sxc+pe5evl$@NG& z*_NPlnTuAi8PR6txH&A2GHVxs3ta2DxzbIW2 zS7q7)yQgtN6fYPfU2lk#M=qkUIdu>>HA!7Jg+-|Y-piTnmr~SjsNwyhNj})t&Maw6 z-yEP;yUq}kq=)qw_*NWpN5Bb*GsFw6C(2r2W-@zSDCjPfl_YdPWq*h@dV-aOUokGR z0`VKhB^CsJ!??tqUomb0NaX(=M=3P47ExbCKZ`?el%og6$fbZ zaVds8ud&dK+zcvtJ2{0rv1TkbXN|DaF_BohTm!XP6{|k=w&*rW9e{dkWy!)&pQL2; z+2T1vAr5ylRm&4-)di!cq!Y)kNThKnETZn6wv7a2wo0UUgW^0%R33jj$@ZbAyYTVT z@$c_87Dh83b9$TH;;d+aqv7 z%H%1k^5SL+kM~h9to5#++!I6lix|!9E z$D@jcRjZY^9g@mNE6RN6A6xvmOxeD>u1jNlQmkXHyMp24{D*jQ1@4&Mz{{;zp`v@b zMZ!R1ZeHu>qizXo<8H`huvmdpf2Iu!u#A{zxX3qWLMlUeF9^VlEb4zaa|KJ@xp8B( z7D21fvDHEPozdxc`0U>^_P>6R{$Kn5pXXUTdFmX>^*PqrJMTyr^V&E%gWk<)bM{kS z^D$*8$c>cxu0WTS?Wpvclky9#gkD_t39ZUZJ2IffXA=s8aQ$<5|2O-8;YO+O&Qe9D zpPyvY$Q2}PPwZ8$+Y7SJZ1ngVs=o~7cy9`2KT`+`FtKW2k z#vQNaaJ*g}FV_seVX#)l7S#LEtkSX4pD=XsUI)^`QY_y^HF-}*Zl^~spWo7lOG~2bFF7{ijZ{Xlh$e2IZ z;2}*)>@;1yuIBvsnpqxCA)E?=rIiiuoiVxnMD(WLE&cK4r#3SEC*g6(uYuLWPcPJ{ z05;`F|zIz{`{a)alef33a+Ox;%eR>i8 zH>EF~n_J~-sIIjr)ToLOlRg>syzq-WnAN#VnN<>6zG^0Cp!v)d@iXrN;|!Lz%a>1u zdr&>kjs-9wO>G-R<}~$OId3t?47QRyX=}eW!4Gf0WX7q9IoPINjCdnwG#D&rJBq#F z@DF)Srnl92#ds(7CqNb2xZeVZe_9+1yAf=xa>&IWvXeHr?4A*gR=qT6@x=a1SsdG_BTQfcjZ5-?jw@|*Zb-Az||O`{1rZ6 z2lSq2{{DgDPau$-?YFCo>3*n!KM=5x!?xWA>MZ{eQU4PlLL0EbdXs7u@e`;q59}Re+y17v^;u_U zH{|e;s_`dq&;9G6FF3tF+qdlB33ymGpJba8X*y@sr&cO!IG=8odifDou>2#AavR#y z87+Y0SGE)$2Y&ePsk8tjpgJ|vHAi!Ol+w#wf! zGc^~^0~a$F-;f>F$_ydsEtiK)OfJUgP?u0gkG}r)yoe+}cBEBjc-LTM<>|uhn}&Wq zHUo)cw>#TOSK)1lY0~x?i42;07LDUOLJU)wJ?SquB%0*jyO-USbd*i#-q*Qx_P+1E zv*A#H_LyGG*UuJ%zx|p|huzwMSF1-Fxn8Uj-Lt$_E`(IAj=+qU&gCCVz2>p=wq#sG z;xW(3b#>NO3~>Rgl7^bd;!o=85kWHajYtW^Z7_ccUY4(@Zt5&raFbPJQ5_=07Do#- zvJ_`xWij|6Oz_8*4~fEh1a)yqzVY-VJX@(OMw|X(5_HtsUeCGOkk|R?jvFS@&$pjc zA8zceZ$!plaE;I~u>2yp!gTBjNikAakqL<_*CP3ILfOU6;Zx`LSja7Vhs~mJmwq)? zIG2t5T=D&=M%#UUG!C%Sqm0L@bI!(%58nBGh$i5Gp2z#2Auvp*<*jxR;l($yDwoJO zHR{=KCtFsE#v^&-roiNLF^59N&Bxcj;t?j%b@J4Mml6`4kvcQ|iTxkaMvK427E}bY zc(Vz&E%jG}oj~?Wi}Uuhw1RE1Iok`8`_G0#Q9QA*QEX7!nmXrRoX%fr9{mf<6!qkbD!Voe2v^vwo?!{Wg>@E%CaYz0Bi49hnumNncGP4haytvApULtO7sw@@WxZn&ckbhvoot6D>E~(I zbd86eoTp8MqJwuFBj35n=@;MeI{dm;Vty^0I!hn5EKKVg0K?{9#|R|3t8!*(k+@}C z^H~}R`Qouz7_%DDI;^D<*V8mmBi+09#L$2wq4`9&A476<8LO>$UmP9~J@f_FwEth$ zSn%Jin*66O_LK!Cd2=Gq{kaLOqoOd|I{imvFrKXhF7wUNA%p5-4GpY4&z1xxK>O~F zxGF37lW*O|ghQj}o{>s0S8@wiGF~L!zQ&_h#`wjBK07oTZp?%njfS6F^I(S6QG;J< zuhL^sK3Yl^=+#`_K~74Kv(z2*QSMj^flCN~eltUkP(3UKxgcQksLob5MkA(R(!K48 zi_C4wmByy|XN=*x8qxl|g9Z?k_jDft6S7I9zPVXw;4*HYdLYx>wA&XK^*kW{h+l{9 z^AL~pgY&$8mq1BWN3=UA+HOYr zm%Fnu4Thy(mHO38;`Kg@4o3X7 zGQ8n!_%T;O$L4i>JC?!9Egwn<`_roGZ`JVMRbBtWM+mCnvI#CpT9)i(_bMoaI0(y9 zid7rrnj0j5{YfMV^I3xDR|Hc9-(u2UeywXK#e2jy>Z=wOrxtHrxpInWJ4F*iK#6Q1 zQ|}v>rLMWzULkM5m84x;3k@_n;7eWF{v^q{raP|bcy4-5V+a3XEb@M&EEwwwzB*qI zpTo_cj*#0c#)j?^2B%h-&!6wwyZ*9k=1ywu5}|H`qMWnxIoqw&>;4rN>UnBoh3!V# zc)a#A2*67J1NOwPCW+Udjg(I!r4kKl=;`A^@UxLApB0!({G7@JRy-&}BTrpo9}T#l z%W%a)Wp1bDME;}0ryfDCmOc*}^E(d5yoI$_L9y#fz|yF6MM1vES!-nRx_CSjX|{SDFU|#m1^_1{7e)LZ_MIKN7Tp zc62FbO@uG6M)x8j(|z=s_%}BSZz3<3r%ujyr?Q8$JP7}$N|zvUcz5Wc# zP`Yoqt>4z*jlBykF9+~Cto;G;V2i>(f6Sj^ZacpbRpjRYf9NrxX}`zGQ&LwAiOgIk z67Cr{$Bb-BJSdR`C@O$}d-@v+llu+KDU?2h83q0;P9s??S26h`pqL@_=NGVFx2B5r zSaft?0-MPGPkYxL*2LOp2SNvlNS7u}s*3a=ks?(PDWOOaqy-Bl^m36VD!rpXqzFh? zK#(F9Kzfxf2;vP8iu3@27v&2aY-3qxH_sXOy+|G#Jv}!LZWj&inWFHec>Mfnccn%j1mO&R!S(g)|Es)M{7+MOvr++mVLL1gG+d^mki9tI6v1) zP>Wg2|-&PQkp7Q7hUUMmpJ_Z{`bJe&(N1h*I zfSaN-U;K^Wh5$pQf!>|2APwct$DxW?F0rw5uQTFC(j`PfAxS|F%Hz! zw&=;9UJ0%=XgeyH_d7DZyE10aO$X@{btGTOWDf5vRX>@p)8}>M=8L(Ph}aQ16*uVy zw|VR%VeHIyEDwyaJQ_caf!noP7 z6en8Jbkh9|bX}icsjfI&H08hK=dp~qr#)b&-2Q0Dp_plqrFuIJ#&=kUjQpn0V-T^l zzAc`4R$cApys3pmP&$KW1)5ULM}tSQ54|{`3tZ}O6gO2`v-|met1ewUrPG7jqi;b^ z3Tbh+O&c^da%q`Xsoo}TU=p*_o4j@U*44wE&Gf8nL8F>Id>bs76;S0fp%Be>DE=7L z%&JQarxhkN1pI|tOx#F>zI2{cr8viR4e}X8DuBtu6ndsGmJOaTlN=pPVF53wtqYUJ zE3V!VJ{Qt!!;Tz6pX5FEpapQ6_4WPIOHzS=x<{~yPv31};VqH9$m&=F>pnZT0G8Y@ z7#(_$Co3yCf1sR`Z7+|(Wl{eA#3gjNQ) zw*p2@=j`0hzQFwp)&ov3s^MOtQ4&?vvQe zU2;s`kpq4BbfzQbUC$jei>zMJzPOT49eM`8jQv5H+7> z=zb3Z+FjCiwf>t{P7XrhUzdyq1qH&v5VUFnJ{IiJfqvdU7+;VA$aeSR2xH)~l1LD* zff70zd38rG9etD_jSq+HLu&A@nFF3+)`k7)_wg|SZttt#D*?@sgdxA39>NLx{#qbZ z?*(=BgdPxbf*6>vfw17nbg=vaI{h(O-`6I06eIwIoxdS!jzfeZfV|5-qTZi_zyXkX zAOYB~E)ZA&0nP7A_#g-Pbw$4%W-P6YmY@1!z338Hm9^*-7P>&|xQLch&vO#jAoT(E-nnwth*JHoYAc*9%U4w} zxsx~Oyh_%~)#;acC7K8Bdx#q^R5eur!8Y^WTZbV!YrcvLcEYvQY z4=HUTv_}bp-lp&0h#0I0?NNVU+yWZ~a`=c6G-pngfcH@c4*{S*AIPx+n{i0!f}y@8 zC$LHRO4r)fIwK9Y0*+uJ{b>WJ@<;n}o~G~^R^4i)xvk3@PJ|X(XyaW0Bcc_-eEvW= zA}vFD{ihCDvF{s#J;=n?0JDt(&YeEkPkFVWw%yXK09>MfPLcjCqC1OPo{W_{nU$I> zR=e7q2Cs1Ed@A+x>&bRi)!{}L>*yNkHVF0~q|`?Lgn9k->kJvlvH11G6LINe{k!wy zxt(6gZ+nt>o4SNDMD z_<)8$Q9bRb-KK7;^~j_A0Vkz@LOOH{65$NJYAA`@s`#U&v3Vuh7LZ$_&tq<^gHHUk znd4v6hP}4jx=stIMX`owpej8SD+O?fxNkV5*OI5vk=WX>)FUaWpVoKOzN(dKE!c*Wg|G8^txRvUu3CL(s z#Y#4#kDiIAC{e-mm&Q56)DNhi)%BixlF8wD9vaW;^BWoKwx5Iah8P(qah)%`dzh0T znoX3P8s|EXq8FTBxp<<^b@Z5Dnh%r40NUx3nt#2NDz0FNO(WJx=(?Q7HkW_H9w3qW z-X6hf&ET65Zkom3H65J|PishCJHCL3Bt1=2j^%D^!!^^wuZ|*mCf1M|)GQQ9&&) z3r|NS_F9adQLp4Jqm!oiSYCJYcI9`}rpS73(Wo{FS70YPC*2Ur0enPto6z-Cn)N%T z_caUJTJ_O9O`5BY*MXN`8w9bB9=wxMAbDNw$=AL~uJ+u&7o-ZW8F%Des%+$r4QAA3 zcM=Gc7%drun^K;XoO^hi<&h`5`YE-|c`<)z31EO97outKQ;%K}&}gc58B1yDxINcp z(ZBSlHms|uXtX^Nk}jO84N;^UH^1OSEvi>LMf&w4&6~~U z4+}=d{NLg~e=cgHh7Z0XZ@(+y>bCCAl_g~#co$ zLnK^T#m85A-+PW9J_F$lC=s}MtNknW9x&z`u)7<8iNZ4kID##g>)=NwTIRDM^s`#P z9O%H{-UBv3MgJBO4|cAsaqR(@lXE8gBmRkTQOfI|Eb(Lgp!kp31K6EcvcEWA1ZxU0 zyQo(U`2h_Qd%(qjZRj5G1S};e;J}`S*gaqeca1s2dJou~$N;MXT%R!kp@KsdYpBir zQBU`PkF0w@-mBd^d%$p}x2>)HGViDY{>9Ny?8vyWQF9-yuR)<;rb!%~=4c?%XGEo1 z5Gap8jwha@i7(~X(>g4NrfRaJ3k}wEI^julql)6lg|S35;LQSSDY;>u6K{4t7R=Y6 zP{Yr)kd=z!#&V7@mk}$JQ%r|WnVWZ@lUsXcd}acrK0QhG3tP;)r7F*D66!yHj+R1} z^K0Rv?bV}x^?q*dD|OU75BXd*XQY9tgoF=4JxZpy-xPd=jKqC9sz;k%6q#r+xY@w) z`pASzjP-W-W9k;Pv!#yrRy{70VrV-@h#dtHvL|6~hw+GTx_E z*E3YDi(ol*V>yg9PT!*Ba0|d54G((Mgp?}JTY6J-tJzS4NE5a^meCi>yqLSbh`Hx9 zKF`M5EulZaq*r0&(u|?WC*r)r2p9Wv!ze3HJlWfLT9n@M*Ug`vjaRX>5E9(U)N&xz zRtac*o!rSdgl*C>M4~P!?8MY@MKWVny>u)q-m*kQ8GqfS=~AQZVe-#|wSvT%(FWr2 zdBn=zuUW9GkM8Es<(yJFV3)*I84yy|cujl2DX8O_~syTxrp-QTO(cuaE+Xac`gtehc zYUG)j6te|la8ee|L_qL$E=axq&WBB4&O^ElDboR!JBSMXQo6gheP9KkO^;EE<);yb zTcYi=2wK!S4ccE~XW(9#WSn4?b-WXav?5snS|dwLa(ojkU8aza zSt~L8J(@ix_|B(cNpZ$rpR!{LaO)?JY0`lg+G=H?YCfE{U9EFpY_8v(A7ps_L^#Fi zp*}P@um%%6{;Gfw_4c>++CS11|K;cYf(HF_EBaHFb4uQ(kD!_taq=Dy^9s(216DS=g)s(dTtjTqH!u2nZHaT2^G`dgzK1+96y5Km}8=i5?0y!``JK<4{I|IHgX z)S4Ii2l}rbh-nerVm~UFNjfnR*thJw6w{ri3Y_r^aKGv9{Qr;skDUo2{#qQ4A@MtH z0kc;O26M&c?$Oq9RPS~xl!%FKINEDYIXI$oc!SlA_kaZ>IAd8(z0-*h3$>EX6!{!c za&}I2Ow-k}Y)|pUCHu-)O_ETyu~$R2iz0d!${#$5+tG5Mp9PZ}^)Lw&P#1~#s_O9B zRX_D%<{VFKfGC(d>S%HJ-LR46=If*JrVJ5K?npj?ipB*f?)RCi-kQ3~) z%HAceo;1pWg_rxL=gnQB$m0sLj?)rWsn3qX7~W^zR+9B->4=5T-ZPA+y!^8X8_%FE zo#?hc^(3m|N=;Ej_M6fQ;-4|Cg0p4LM7lEOV1VAcZXx8`MJ&mgb_uHbdM+C(3;BJotP>{z5t%r)eu{AKTi?hJ>F9=d7IJ8(Z_x?e8|#4NU0JWY`sw78{p zO@)%Ioo;qHlgeyA_wca&doQXdCNkxAHMq5CuqNn zdX{!ca}Tg{RX9H=+5GO%c%#L8`K7|*ugcLP2945-{0kRjlKGqcL3@^&)S>hcFPFwH zOP;gSwRppUQPUmcIwmBx$}$#=3!jYa0dIGo zNZ?JkQ(C^5fh{Z|mSzPmeKuBn5AxVO>RV(t^E7=(wA;JAgzAYj;SwXhhF<+%*=qea zKar0RugUnL*LQSx{Mu%ilcl|^-rTQdVZoq7mQxi?zL>KOLZ+UHDe}4Xr-w)}9F08E z_@XCSdFfb4zw9I?qajtYSKyLN5m#cFcjIZ{(~X=duXFI7oceibTQ-8U6JJ6gJMNj3 zH^-SihOAwQU?O20i_To1c;M__b>w`04#jF434PqGIwMJ{n^tw3nCO?P*!%2(ve-yR z&PsS4lp(%NG-y;CNm)SyEy$#1AT(9|h5z{vw#+J6~W{TI#ktyk>V!zvygYJVen zzrNWM&FSKT>M?z4a&GG$vu79n@i?FDMu#unov3i@*(~K%F(NB(dAgw>KO*zn)?SWd zWmQPlxky}bGS}={+5C|5IsCeZLT2tjYt#}_FrH;vTO_ie9c)=crLK;j;|t=o?nKB? op<mKA|ul@$=;M5!xiX{suqRdmfg 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 op<v=cd!1|_s!$t;|4&WEUzRFfIt8MLcV~< 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�PyD8nL>m2R6C1_`m5%rWrM0yzDr&xhLG<}Q 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 F{{@RUl%oIu literal 0 HcmV?d00001 diff --git a/docs/design/screenshots/check-sched4.png b/docs/design/screenshots/check-sched4.png new file mode 100644 index 0000000000000000000000000000000000000000..5fd68eb2a359d51e956546f17130d32fc5e3f693 GIT binary patch literal 34021 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 P{F`I%FY*=QZ}0vWqZX0b literal 0 HcmV?d00001 diff --git a/docs/design/screenshots/check-schedule.png b/docs/design/screenshots/check-schedule.png new file mode 100644 index 0000000000000000000000000000000000000000..98de21af6759c2b7800b4903cade28bcf5c83ea7 GIT binary patch literal 30252 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 Z4vaiC^pbsADD^M8mGAytP7iuK`d>n#OPBxv literal 0 HcmV?d00001 diff --git a/docs/design/src/Cabinet.jsx b/docs/design/src/Cabinet.jsx new file mode 100644 index 0000000..a4e23b1 --- /dev/null +++ b/docs/design/src/Cabinet.jsx @@ -0,0 +1,355 @@ +// Medicine Cabinet — the hero screen. +// Layout: summary strip + filter rail + "cabinet grid" of med cards with +// days-of-supply bar, expiry, lot info, and inline quick actions. + +const { CABINET, MED, MEDICINES, PRICES, STORES } = window.MESHI; + +const cabinetStatus = (row) => { + const days = Math.floor(row.qty / (row.perDose * (1 / row.daysPerUnit))); + // days until expiry + const exp = new Date(row.expiry); + const daysToExp = Math.round((exp - new Date('2026-04-18')) / 86400000); + let level = 'ok'; + if (row.qty <= row.lowAt) level = 'low'; + if (daysToExp < 60) level = level === 'low' ? 'critical' : 'expiring'; + if (row.qty <= Math.ceil(row.lowAt / 2)) level = 'critical'; + return { days, daysToExp, level }; +}; + +const fmtJPY = (n) => '¥' + Number(n).toLocaleString('en-US'); + +const CabinetSummary = () => { + const rows = CABINET.map(r => ({ ...r, ...cabinetStatus(r) })); + const total = rows.length; + const low = rows.filter(r => r.level === 'low' || r.level === 'critical').length; + const expiring = rows.filter(r => r.level === 'expiring' || r.level === 'critical').length; + const inventoryValue = rows.reduce((s, r) => { + const p = PRICES.find(p => p.medId === r.medId); + return s + (p ? (p.price / p.pkg) * r.qty : 0); + }, 0); + + const stats = [ + { label: 'Items', value: total, hint: 'distinct medicines', tone: 'ink' }, + { label: 'Running low', value: low, hint: 'need a refill soon', tone: 'danger' }, + { label: 'Expiring <60d', value: expiring, hint: 'check dates', tone: 'warn' }, + { label: 'Cabinet value', value: fmtJPY(Math.round(inventoryValue)), hint: 'at last paid price', tone: 'brand' }, + ]; + + return ( +
+ {stats.map(s => ( +
+
{s.label}
+
{s.value}
+
{s.hint}
+
+ ))} +
+ ); +}; + +const DaysBar = ({ days, low = 14 }) => { + const cap = 60; + const pct = Math.min(100, (days / cap) * 100); + const tone = days <= low / 2 ? 'critical' : days <= low ? 'low' : 'ok'; + return ( +
+
+
+
+
+
+ {days} + days +
+
+ ); +}; + +const CabinetCard = ({ row, onOpen }) => { + const med = MED[row.medId]; + const { days, daysToExp, level } = cabinetStatus(row); + const price = PRICES.find(p => p.medId === row.medId); + + return ( + + ); +}; + +const CabinetFilters = ({ filter, setFilter, view, setView, query, setQuery }) => { + const tabs = [ + { id: 'all', label: 'All', count: CABINET.length }, + { id: 'low', label: 'Running low', count: CABINET.filter(r => cabinetStatus(r).level !== 'ok').length }, + { id: 'rx', label: 'Prescription', count: CABINET.filter(r => MED[r.medId].category === 'Prescription').length }, + { id: 'supplement', label: 'Supplements', count: CABINET.filter(r => MED[r.medId].category === 'Supplement').length }, + ]; + return ( +
+
+ {tabs.map(t => ( + + ))} +
+
+
+ + setQuery(e.target.value)} /> +
+
+ + + +
+ +
+
+ ); +}; + +const CabinetTable = ({ rows, onOpen }) => ( +
+
+
Medicine
+
Qty
+
Supply
+
Expiry
+
Unit cost
+
Status
+
+ {rows.map(r => { + const med = MED[r.medId]; + const { days, daysToExp, level } = cabinetStatus(r); + const price = PRICES.find(p => p.medId === r.medId); + return ( + + ); + })} +
+); + +const CabinetShelf = ({ rows, onOpen }) => { + // Group by category, then render as "shelves" + const groups = {}; + rows.forEach(r => { + const cat = MED[r.medId].category; + (groups[cat] = groups[cat] || []).push(r); + }); + return ( +
+ {Object.entries(groups).map(([cat, items]) => ( +
+
{cat}
+
+ {items.map(r => { + const med = MED[r.medId]; + const { days, level } = cabinetStatus(r); + const fillPct = Math.min(100, (days / 60) * 100); + return ( + + ); + })} +
+
+
+ ))} +
+ ); +}; + +const CabinetDetail = ({ row, onClose }) => { + if (!row) return null; + const med = MED[row.medId]; + const { days, daysToExp, level } = cabinetStatus(row); + const price = PRICES.find(p => p.medId === row.medId); + const allPrices = PRICES.filter(p => p.medId === row.medId); + const cheapest = allPrices.reduce((a, b) => (a && a.price / a.pkg < b.price / b.pkg ? a : b), null); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
+ +
+
+
{med.name}
+
{med.strength} · {med.form} · {med.category}
+
+
+ +
+ +
+
+
On hand
+
{row.qty} {row.unit}
+
+
+
Supply
+
{days} days
+
+
+
Expires
+
{row.expiry}
+
+
+ +
+ + + + +
+ +
+ +
+
Prices across stores
+
+ {allPrices.map(p => { + const s = STORES.find(s => s.id === p.store); + const isCheap = p === cheapest; + return ( +
+
+
{s?.name}
+
{p.date}
+
+
+
{fmtJPY(p.price)}
+
{fmtJPY(Math.round(p.price / p.pkg))} / {p.unit}
+
+ {isCheap && Best} +
+ ); + })} +
+
+ + {med.notes && ( +
+
Notes
+
{med.notes}
+
+ )} +
+
+ ); +}; + +const CabinetPage = () => { + const [filter, setFilter] = useState('all'); + const [view, setView] = useState('grid'); + const [query, setQuery] = useState(''); + const [detail, setDetail] = useState(null); + + const rows = useMemo(() => { + return CABINET + .filter(r => { + if (filter === 'low') return cabinetStatus(r).level !== 'ok'; + if (filter === 'rx') return MED[r.medId].category === 'Prescription'; + if (filter === 'supplement') return MED[r.medId].category === 'Supplement'; + return true; + }) + .filter(r => !query || MED[r.medId].name.toLowerCase().includes(query.toLowerCase())) + .sort((a, b) => cabinetStatus(a).days - cabinetStatus(b).days); + }, [filter, query]); + + return ( +
+ +
+ +
+ {view === 'grid' && ( +
+ {rows.map(r => setDetail(r)} />)} +
+ )} + {view === 'table' && } + {view === 'shelf' && } + setDetail(null)} /> +
+ ); +}; + +window.CabinetPage = CabinetPage; +window.cabinetStatus = cabinetStatus; +window.fmtJPY = fmtJPY; diff --git a/docs/design/src/Dashboard.jsx b/docs/design/src/Dashboard.jsx new file mode 100644 index 0000000..44705b9 --- /dev/null +++ b/docs/design/src/Dashboard.jsx @@ -0,0 +1,251 @@ +// Dashboard — today at a glance. Integrated view of meds + intake + refills + spending. + +const { TODAY_INTAKE, MED, CABINET, REGIMENS, SPENDING, PURCHASES, ACTIVITY, PRICES, STORES } = window.MESHI; + +const DashboardPage = ({ onNav }) => { + const [intake, setIntake] = useState(TODAY_INTAKE); + + const taken = intake.filter(i => i.status === 'taken').length; + const total = intake.length; + const pct = Math.round((taken / total) * 100); + + const lowMeds = CABINET.map(r => ({ r, s: cabinetStatus(r) })).filter(x => x.s.level !== 'ok').sort((a, b) => a.s.days - b.s.days); + + const thisMonth = SPENDING[SPENDING.length - 1].amount; + const lastMonth = SPENDING[SPENDING.length - 2].amount; + const spendDelta = ((thisMonth - lastMonth) / lastMonth) * 100; + + const toggleTake = (id) => { + setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i)); + }; + + // Days supply across meds for mini sparkline + const sparkMax = 60; + const supplyData = CABINET.map(r => ({ med: MED[r.medId], days: cabinetStatus(r).days })) + .sort((a, b) => a.days - b.days); + + return ( +
+
+
+
Saturday · April 18
+
Good evening, aeri.
+
Evening pills are due in 2h 34m. Everything else is on track.
+
+
+ + + + {taken}/{total} + DOSES TAKEN + +
+
+ +
+
+
+
+
Today's schedule
+
{total - taken} upcoming · {taken} completed
+
+ +
+
+ {['08:00', '21:00'].map(time => { + const items = intake.filter(i => i.scheduled === time); + if (!items.length) return null; + const allTaken = items.every(i => i.status === 'taken'); + return ( +
+
+
{time}
+
{time === '08:00' ? 'Morning' : 'Evening'}
+
+
+ {items.map(i => { + const m = MED[i.medId]; + return ( + + ); + })} +
+
+ ); + })} +
+
+ +
+
+
+
Running low
+
{lowMeds.length} need attention
+
+ +
+
+ {lowMeds.slice(0, 4).map(({ r, s }) => { + const m = MED[r.medId]; + return ( +
+
+ +
+
+
{m.name}
+
{r.qty} {r.unit}
+
+
+
{s.days}d
+
left
+
+
+ ); + })} +
+
+ +
+
+
+
Spending · 6 months
+
{fmtJPY(thisMonth)} this month {spendDelta < 0 ? '↓' : '↑'} {Math.abs(Math.round(spendDelta))}%
+
+ +
+
+ + {SPENDING.map((m, i) => { + const max = Math.max(...SPENDING.map(s => s.amount)); + const h = (m.amount / max) * 100; + const x = 20 + i * 56; + const isLast = i === SPENDING.length - 1; + return ( + + + {m.month.slice(5)} + + {(m.amount / 1000).toFixed(0)}k + + + ); + })} + +
+
+ +
+
+
+
Cabinet — days of supply
+
At current usage
+
+ +
+
+ {supplyData.map(s => { + const pct = Math.min(100, (s.days / sparkMax) * 100); + const tone = s.days <= 7 ? 'critical' : s.days <= 14 ? 'low' : 'ok'; + return ( +
+
{s.med.name}
+
+
+
+
{s.days}d
+
+ ); + })} +
+
+ +
+
+
+
Pending orders
+
{PURCHASES.filter(p => p.status === 'pending').length} awaiting arrival
+
+ +
+
+ {PURCHASES.filter(p => p.status === 'pending').map(p => { + const s = STORES.find(s => s.id === p.store); + const total = p.items.reduce((a, i) => a + i.price, 0); + return ( +
+
+
+
{s?.name}
+
{p.items.map(i => MED[i.medId].name).join(', ')}
+
+
+
{fmtJPY(total)}
+
ordered {p.date}
+
+
+ ); + })} + {PURCHASES.filter(p => p.status === 'received').slice(0, 2).map(p => { + const s = STORES.find(s => s.id === p.store); + return ( +
+
+
+
{s?.name} · {p.items.length} items received
+
+
{p.date}
+
+ ); + })} +
+
+ +
+
+
+
Recent activity
+
Cabinet changes
+
+ +
+
+ {ACTIVITY.slice(0, 5).map(a => { + const m = MED[a.medId]; + return ( +
+ {a.type} + + {m.name} + + {a.delta > 0 ? '+' : ''}{a.delta} + + {a.before}→{a.after} + + {a.at.slice(5)} +
+ ); + })} +
+
+
+
+ ); +}; + +window.DashboardPage = DashboardPage; diff --git a/docs/design/src/Icon.jsx b/docs/design/src/Icon.jsx new file mode 100644 index 0000000..4678701 --- /dev/null +++ b/docs/design/src/Icon.jsx @@ -0,0 +1,44 @@ +// Minimal inline-SVG icon set. Stroke-based, 1.6px weight, 20px box. +const Icon = ({ name, size = 18, className = '', style }) => { + const s = { width: size, height: size, display: 'inline-block', flexShrink: 0, ...style }; + const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', className }; + const paths = { + dashboard: <>, + cabinet: <>, + pill: <>, + clock: <>, + list: <>, + calendar: <>, + store: <>, + tag: <>, + truck: <>, + refresh: <>, + search: <>, + plus: <>, + check: <>, + x: <>, + chev: <>, + chevDown: <>, + alert: <>, + bell: <>, + filter: <>, + settings: <>, + sun: <>, + moon: <>, + home: <>, + trash: <>, + edit: <>, + arrow: <>, + vial: <>, + capsule: <>, + injection: <>, + trend: <>, + fridge: <>, + box: <>, + yen: <>, + zap: <>, + }; + return {paths[name] || null}; +}; + +window.Icon = Icon; diff --git a/docs/design/src/OtherPages.jsx b/docs/design/src/OtherPages.jsx new file mode 100644 index 0000000..e453aa1 --- /dev/null +++ b/docs/design/src/OtherPages.jsx @@ -0,0 +1,518 @@ +// Medium-fi placeholder pages for the remaining medicine + purchasing sections. +const { MED: M2, MEDICINES: MEDS2, CABINET: C2, REGIMENS: REG2, STORES: ST2, PRICES: PR2, PURCHASES: PU2, ACTIVITY: AC2 } = window.MESHI; + +// ---- Library ---- +const LibraryPage = () => { + const [q, setQ] = useState(''); + const rows = MEDS2.filter(m => m.name.toLowerCase().includes(q.toLowerCase())); + return ( +
+
+
+ + setQ(e.target.value)} style={{width:'100%'}}/> +
+
+ + + +
+
+
+ {rows.map((m, i) => ( +
+
+ +
+
+
{m.name}
+
{m.strength} · {m.form}
+
+ {m.category} + + +
+ ))} +
+
+ ); +}; + +// ---- Regimens ---- +const RegimensPage = () => ( +
+
+
+ + +
+ +
+
+ {REG2.map(r => ( +
+
+
+
+ Aeri's {r.name} + Active +
+
{r.items.length} medication{r.items.length>1?'s':''} · {r.time} · {r.freq}
+
+
+ + + +
+
+
+ {r.items.map(it => { + const m = M2[it.medId]; + return ( + + + {m.name} + {it.qty} {it.unit} + + ); + })} +
+
+ ))} +
+
+); + +// ---- Organizer ---- +const OrganizerPage = () => { + const [regimenId, setRegimenId] = useState(REG2[1].id); + const [days, setDays] = useState(7); + const regimen = REG2.find(r => r.id === regimenId); + + // Compute inventory impact: for each item in regimen, how many units will be consumed? + const impact = regimen.items.map(it => { + const m = M2[it.medId]; + const cabEntry = C2.find(c => c.medId === it.medId); + const onHand = cabEntry ? cabEntry.qty : 0; + const needed = it.qty * days; + const after = onHand - needed; + const perDay = cabEntry ? (cabEntry.perDose || 1) / (cabEntry.daysPerUnit || 1) : 1; + const daysAfter = perDay > 0 ? Math.floor(Math.max(0, after) / perDay) : 0; + const status = after < 0 ? 'short' : after < needed * 0.5 ? 'low' : 'ok'; + return { m, it, onHand, needed, after, daysAfter, status, cabEntry }; + }); + + const anyShort = impact.some(i => i.status === 'short'); + const anyLow = impact.some(i => i.status === 'low'); + + return ( +
+
+
+
+
Fill from regimen
+ +
{regimen.time} · {regimen.items.length} medicines · {regimen.freq}
+
+
+
+
Days to fill
+
+ + {days} + +
+
+ {[3, 7, 14, 30].map(d => ( + + ))} +
+
+
+
+
+
This fill will dispense
+
+ {impact.reduce((a,b) => a + b.needed, 0)} units across {impact.length} medicines +
+
+ {anyShort ? ( +
+ +
+ Not enough stock. {impact.filter(i => i.status === 'short').length} medicine{impact.filter(i => i.status === 'short').length > 1 ? 's are' : ' is'} short for this fill. +
+ +
+ ) : anyLow ? ( +
+ +
Low after fill. Some meds will drop below 50% headroom.
+
+ ) : ( +
+ +
Ready to dispense. Cabinet has enough stock with comfortable headroom.
+
+ )} +
+ + +
+ +
+
+
+ + {/* Inventory impact table */} +
+
+
+
Inventory impact
+
What this fill will take from the cabinet
+
+
+
+
+
Medicine
+
On hand
+
Needed
+
After fill
+
Days left
+
+ {impact.map(row => { + const m = row.m; + const pct = row.onHand > 0 ? Math.max(0, Math.min(100, (row.after / row.onHand) * 100)) : 0; + return ( +
+
+
+ +
+
+
{m.name}
+
{row.it.qty} × {days} days = {row.needed} {m.form.toLowerCase()}
+
+
+
{row.onHand}
+
−{row.needed}
+
+
+
+
+
+ {row.after < 0 ? row.after : row.after} +
+
+ {row.status === 'short' + ? Short + : {row.daysAfter}d} +
+
+ ); + })} +
+
+
+ ); +}; + +// ---- Activity ---- +const ActivityPage = () => { + const { SPENDING } = window.MESHI; + return ( +
+
+
+
+
Spending summary
+
Last 6 months
+
+
+ + +
+
+
+ + {SPENDING.map((s, i) => { + const max = Math.max(...SPENDING.map(x=>x.amount)); + const h = (s.amount / max) * 140; + const x = 20 + i * 56; + return ( + + + {s.month.slice(5)} + + ); + })} + +
+
Total spent
{fmtJPY(SPENDING.reduce((a,b)=>a+b.amount,0))}
+
Monthly average
{fmtJPY(Math.round(SPENDING.reduce((a,b)=>a+b.amount,0)/SPENDING.length))}
+
Top medicine
Semaglutide {fmtJPY(56062)}
+
+
+
+ +
+
+
+
Cabinet activity
+
{AC2.length} events
+
+
+ + +
+
+
+ {AC2.map(a => { + const m = M2[a.medId]; + return ( +
+ + {a.type} + {m.name} + {a.delta > 0?'+':''}{a.delta} + {a.before}→{a.after} + + {a.at} +
+ ); + })} +
+
+
+ ); +}; + +// ---- Stores ---- +const StoresPage = () => ( +
+
+
+
+ + +
+ +
+
+ {ST2.map(s => ( +
+
+
+
{s.name}
+
{s.url}
+
+
+ +
+
+
+ {s.tags.map(t => {t})} +
+
+
+ {PR2.filter(p=>p.store===s.id).length} prices tracked + {PU2.filter(p=>p.store===s.id).length} orders +
+
+ ))} +
+
+); + +// ---- Prices ---- +const PricesPage = () => ( +
+
+
+ + +
+ +
+ +
+
Store comparison
Best price highlighted
+
+
+
Store
Price
Per unit
Date
+
+ {PR2.filter(p=>p.medId==='semaglutide').map(p => { + const s = ST2.find(s=>s.id===p.store); + const isBest = p.price/p.pkg === Math.min(...PR2.filter(x=>x.medId===p.medId).map(x=>x.price/x.pkg)); + return ( +
+
+ {s?.name} + {isBest && Cheapest} +
+
{fmtJPY(p.price)}
+
{fmtJPY(Math.round(p.price/p.pkg))}
+
{p.date}
+
+ ); + })} +
+
+ +
+
All price records
{PR2.length} entries
+
+
+
Medicine
Store
Qty
Price
Per unit
Date
+
+ {PR2.map(p => { + const s = ST2.find(s=>s.id===p.store); + const m = M2[p.medId]; + return ( +
+
{m.name}
+
{s?.name}
+
{p.pkg} {p.unit}
+
{fmtJPY(p.price)}
+
{fmtJPY(Math.round(p.price/p.pkg))}
+
{p.date}
+
+ ); + })} +
+
+
+); + +// ---- Refills ---- +const RefillsPage = () => { + const low = C2.map(r => ({r, s: cabinetStatus(r)})).filter(x => x.s.level !== 'ok'); + return ( +
+
+
+
+
Refill alerts
+
Threshold: 14 days
+
+ +
+
+ {low.map(({r, s}) => { + const m = M2[r.medId]; + const bestPrice = PR2.filter(p=>p.medId===r.medId).sort((a,b)=> a.price/a.pkg - b.price/b.pkg)[0]; + const bestStore = bestPrice && ST2.find(st=>st.id===bestPrice.store); + return ( +
+
+ +
+
+
{m.name} {m.strength}
+
{s.days} days left · {r.qty} {r.unit} in cabinet
+
+
+
Suggested
+
{bestPrice?.pkg || 30} units
+
+
+
Best price
+
{bestStore?.name} · {bestPrice ? fmtJPY(bestPrice.price) : '—'}
+
+ +
+ ); + })} +
+
+
+
+
Refill lists
Create from alerts or start blank
+ +
+
No refill lists yet. Create one above or generate from alerts.
+
+
+ ); +}; + +// ---- Purchases ---- +const PurchasesPage = () => ( +
+
+
+ +
+ +
+ {['pending', 'received'].map(status => { + const items = PU2.filter(p => p.status === status); + if (!items.length) return null; + return ( +
+
{status === 'pending' ? 'Pending arrival' : 'Received'}
+
+ {items.map(p => { + const s = ST2.find(s => s.id === p.store); + const total = p.items.reduce((a,i) => a + i.price, 0); + return ( +
+
+
+
{s?.name}
+
{p.date}
+
+ {status} +
+
+ {p.items.map(i => { + const m = M2[i.medId]; + return ( +
+ {m.name} + {i.qty} {i.unit} · {fmtJPY(i.price)} +
+ ); + })} +
+
+
{fmtJPY(total)}
+ {status === 'pending' &&
+ + +
} +
+
+ ); + })} +
+
+ ); + })} +
+); + +// ---- Settings ---- +const SettingsPage = () => ( +
+
+
Household
+
+
NameRed Panda Den
+
Invite code2496F-7CF
+
Members1
+
+
+
+
Account
+
+

Account settings are managed through Keycloak.

+ +
+
+
+); + +Object.assign(window, { LibraryPage, RegimensPage, OrganizerPage, ActivityPage, StoresPage, PricesPage, RefillsPage, PurchasesPage, SettingsPage }); diff --git a/docs/design/src/Schedule.jsx b/docs/design/src/Schedule.jsx new file mode 100644 index 0000000..d97243f --- /dev/null +++ b/docs/design/src/Schedule.jsx @@ -0,0 +1,162 @@ +// Schedule & Intake Log — slot-based, collapsed by default +const { TODAY_INTAKE: INTAKE_SEED, MED: MED_S, REGIMENS: REGS_S } = window.MESHI; + +const SchedulePage = () => { + const [intake, setIntake] = useState(INTAKE_SEED); + const [day, setDay] = useState(5); + const [expanded, setExpanded] = useState(null); // slot time + + const weekDays = ['Mon 13', 'Tue 14', 'Wed 15', 'Thu 16', 'Fri 17', 'Sat 18', 'Sun 19']; + const adherence = [1.0, 1.0, 1.0, 0.88, 1.0, 0.50, 0.0]; + + const toggle = (id) => setIntake(intake.map(i => i.id === id ? { ...i, status: i.status === 'taken' ? 'upcoming' : 'taken', takenAt: '21:04' } : i)); + const markSlot = (time) => setIntake(intake.map(i => i.scheduled === time ? { ...i, status: 'taken', takenAt: time } : i)); + + // Group by scheduled time + const slots = [ + { time: '08:00', label: 'Morning', icon: 'sun' }, + { time: '21:00', label: 'Evening', icon: 'moon' }, + ].map(s => { + const items = intake.filter(i => i.scheduled === s.time); + const done = items.filter(i => i.status === 'taken').length; + return { ...s, items, done, total: items.length, complete: done === items.length && items.length > 0 }; + }); + + const totalDone = intake.filter(i => i.status === 'taken').length; + + return ( +
+ {/* Week strip — adherence heatmap */} +
+
+
This week
+
+ {Math.round(adherence.reduce((a,b)=>a+b,0)/adherence.length*100)}% adherence +
+
+
+ {weekDays.map((d, i) => { + const isToday = i === day; + const a = adherence[i]; + return ( + + ); + })} +
+
+ +
+
+
+
+
Saturday, April 18
+
{totalDone} of {intake.length} doses · 18:30 now
+
+ +
+ +
+ {slots.map(slot => { + const isOpen = expanded === slot.time; + const now = 18.5; + const slotHr = parseInt(slot.time); + const status = slot.complete ? 'done' : slotHr < now ? 'overdue' : 'upcoming'; + return ( +
+ } + +
+ + {isOpen && ( +
+ {slot.items.map(i => { + const m = MED_S[i.medId]; + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ +
+ Tap a slot to see individual medicines. Off-schedule doses logged separately. +
+
+ +
+
+
+
Active regimens
+
{REGS_S.length} configured
+
+
+
+ {REGS_S.map(r => ( +
+
+
+
Aeri's {r.name}
+
{r.time} · {r.freq}
+
+ Active +
+
+ {r.items.map(it => { + const m = MED_S[it.medId]; + return ( + + + {m.name} + + ); + })} +
+
+ ))} +
+
+
+
+ ); +}; + +window.SchedulePage = SchedulePage; diff --git a/docs/design/src/Shell.jsx b/docs/design/src/Shell.jsx new file mode 100644 index 0000000..ac11d8d --- /dev/null +++ b/docs/design/src/Shell.jsx @@ -0,0 +1,110 @@ +// App shell: sidebar + topbar + routing +const { useState, useEffect, useMemo, useRef, createContext, useContext } = React; + +const AppCtx = createContext(null); +const useApp = () => useContext(AppCtx); + +const NAV = [ + { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, + { id: 'cabinet', label: 'Cabinet', icon: 'cabinet', section: 'Medicines', badge: 2 }, + { id: 'schedule', label: 'Schedule & Log', icon: 'clock', section: 'Medicines' }, + { id: 'regimens', label: 'Regimens', icon: 'list', section: 'Medicines' }, + { id: 'organizer', label: 'Pill Organizer', icon: 'calendar', section: 'Medicines' }, + { id: 'library', label: 'Library', icon: 'pill', section: 'Medicines' }, + { id: 'refills', label: 'Shopping list', icon: 'refresh', section: 'Medicines', badge: 1 }, + { id: 'purchases', label: 'Purchases', icon: 'truck', section: 'Medicines' }, + { id: 'prices', label: 'Prices', icon: 'tag', section: 'Medicines' }, + { id: 'stores', label: 'Stores', icon: 'store', section: 'Medicines' }, + { id: 'activity', label: 'Activity & Spend', icon: 'trend', section: 'Medicines' }, + { id: 'settings', label: 'Settings', icon: 'settings' }, +]; + +const Sidebar = ({ route, onNav }) => { + const grouped = useMemo(() => { + const g = {}; + NAV.forEach(n => { (g[n.section] = g[n.section] || []).push(n); }); + return g; + }, []); + return ( + + ); +}; + +const Topbar = ({ title, subtitle, crumbs, onSearch, actions, theme, onToggleTheme }) => { + return ( +
+
+ {crumbs && crumbs.length > 1 && ( +
+ {crumbs.map((c, i) => ( + + {i > 0 && } + {c} + + ))} +
+ )} +
+

{title}

+ {subtitle &&
{subtitle}
} +
+
+
+
+ + + ⌘K +
+ + + {actions} +
+
+ ); +}; + +window.Shell = { Sidebar, Topbar, AppCtx, useApp, NAV }; diff --git a/docs/design/src/data.jsx b/docs/design/src/data.jsx new file mode 100644 index 0000000..7320fdc --- /dev/null +++ b/docs/design/src/data.jsx @@ -0,0 +1,129 @@ +// Mock data for MeshiTrack. Grounded in the real household ("Red Panda Den"), +// extended with plausible timestamps, quantities, purchases and price history. + +const MEDICINES = [ + { id: 'semaglutide', name: 'Semaglutide', strength: '14 mg', form: 'Tablet', category: 'Prescription', color: 1, notes: 'Weekly GLP-1. Store at room temp.' }, + { id: 'estradiol', name: 'Estradiol Valerate', strength: '10 mg', form: 'Injection', category: 'Prescription', color: 4, notes: 'Intramuscular, weekly.' }, + { id: 'spironolactone', name: 'Spironolactone', strength: '100 mg', form: 'Tablet', category: 'Prescription', color: 3 }, + { id: 'tadalafil', name: 'Tadalafil', strength: '5 mg', form: 'Tablet', category: 'Prescription', color: 3 }, + { id: 'dutasteride', name: 'Dutasteride', strength: '0.5 mg', form: 'Tablet', category: 'Prescription', color: 3 }, + { id: 'progesterone', name: 'Progesterone', strength: '200 mg', form: 'Capsule', category: 'Prescription', color: 4 }, + { id: 'magnesium', name: 'Magnesium', strength: '400 mg', form: 'Capsule', category: 'Supplement', color: 5 }, + { id: 'vitamin-d', name: 'Vitamin D', strength: '2000 IU', form: 'Capsule', category: 'Supplement', color: 2 }, + { id: 'vitamin-a', name: 'Vitamin A', strength: '5000 IU', form: 'Capsule', category: 'Supplement', color: 2 }, +]; + +const MED = Object.fromEntries(MEDICINES.map(m => [m.id, m])); + +// Cabinet inventory — summed across item lots +const CABINET = [ + { medId: 'dutasteride', qty: 80, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-08-12', lowAt: 14 }, + { medId: 'estradiol', qty: 2, unit: 'vials', lots: 1, daysPerUnit: 14, perDose: 0.5, expiry: '2026-11-30', lowAt: 1 }, + { medId: 'magnesium', qty: 115, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-02-01', lowAt: 20 }, + { medId: 'progesterone', qty: 35, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2027-04-30', lowAt: 14 }, + { medId: 'semaglutide', qty: 26, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-09-15', lowAt: 7 }, + { medId: 'spironolactone', qty: 62, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 2, expiry: '2027-01-20', lowAt: 14 }, + { medId: 'tadalafil', qty: 7, unit: 'tablets', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2026-12-01', lowAt: 7 }, + { medId: 'vitamin-a', qty: 211, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-05-05', lowAt: 30 }, + { medId: 'vitamin-d', qty: 96, unit: 'capsules', lots: 1, daysPerUnit: 1, perDose: 1, expiry: '2028-03-10', lowAt: 30 }, +]; + +// Daily schedule regimens +const REGIMENS = [ + { + id: 'morning', name: 'Morning Pills', time: '08:00', freq: 'daily', active: true, + items: [ + { medId: 'semaglutide', qty: 1, unit: 'tablet' }, + ], + }, + { + id: 'evening', name: 'Evening Pills', time: '21:00', freq: 'daily', active: true, + items: [ + { medId: 'spironolactone', qty: 1, unit: 'tablet' }, + { medId: 'tadalafil', qty: 1, unit: 'tablet' }, + { medId: 'dutasteride', qty: 1, unit: 'tablet' }, + { medId: 'progesterone', qty: 1, unit: 'capsule' }, + { medId: 'vitamin-a', qty: 1, unit: 'capsule' }, + { medId: 'magnesium', qty: 1, unit: 'capsule' }, + { medId: 'vitamin-d', qty: 1, unit: 'capsule' }, + ], + }, + { + id: 'injections', name: 'Injections', time: 'Sun 10:00', freq: 'weekly', active: true, + items: [ + { medId: 'estradiol', qty: 0.5, unit: 'vial' }, + ], + }, +]; + +// Today's intake — some done, some upcoming +const TODAY_INTAKE = [ + { id: 't1', regimen: 'morning', medId: 'semaglutide', qty: 1, scheduled: '08:00', status: 'taken', takenAt: '08:12' }, + { id: 't2', regimen: 'evening', medId: 'spironolactone', qty: 1, scheduled: '21:00', status: 'upcoming' }, + { id: 't3', regimen: 'evening', medId: 'tadalafil', qty: 1, scheduled: '21:00', status: 'upcoming' }, + { id: 't4', regimen: 'evening', medId: 'dutasteride', qty: 1, scheduled: '21:00', status: 'upcoming' }, + { id: 't5', regimen: 'evening', medId: 'progesterone', qty: 1, scheduled: '21:00', status: 'upcoming' }, + { id: 't6', regimen: 'evening', medId: 'vitamin-a', qty: 1, scheduled: '21:00', status: 'upcoming' }, + { id: 't7', regimen: 'evening', medId: 'magnesium', qty: 1, scheduled: '21:00', status: 'upcoming' }, + { id: 't8', regimen: 'evening', medId: 'vitamin-d', qty: 1, scheduled: '21:00', status: 'upcoming' }, +]; + +// Stores +const STORES = [ + { id: 'osakado', name: 'Osakado', url: 'osakado.clinic', tags: ['pharmacy', 'online'], currency: 'JPY', active: true }, + { id: 'inhouse', name: 'In-House Pharmacy', url: 'local', tags: ['pharmacy'], currency: 'JPY', active: true }, + { id: 'amazon-jp', name: 'Amazon JP', url: 'amazon.co.jp', tags: ['online', 'bulk'], currency: 'JPY', active: true }, + { id: 'matsumoto', name: 'Matsumoto Kiyoshi', url: 'matsukiyo.co.jp', tags: ['pharmacy', 'grocery'], currency: 'JPY', active: true }, +]; + +// Prices — multiple stores per med where it makes sense +const PRICES = [ + { medId: 'semaglutide', store: 'osakado', price: 24862, pkg: 30, unit: 'tablet', date: '2026-03-29' }, + { medId: 'semaglutide', store: 'inhouse', price: 31200, pkg: 30, unit: 'tablet', date: '2026-02-10' }, + { medId: 'tadalafil', store: 'osakado', price: 4537, pkg: 30, unit: 'tablet', date: '2026-04-02' }, + { medId: 'tadalafil', store: 'amazon-jp', price: 5180, pkg: 30, unit: 'tablet', date: '2026-03-15' }, + { medId: 'spironolactone', store: 'osakado', price: 2190, pkg: 100, unit: 'tablet', date: '2026-03-01' }, + { medId: 'dutasteride', store: 'osakado', price: 3860, pkg: 30, unit: 'tablet', date: '2026-03-20' }, + { medId: 'estradiol', store: 'osakado', price: 9800, pkg: 5, unit: 'vial', date: '2026-02-28' }, + { medId: 'progesterone', store: 'osakado', price: 2980, pkg: 30, unit: 'capsule', date: '2026-03-05' }, + { medId: 'magnesium', store: 'amazon-jp', price: 1480, pkg: 120, unit: 'capsule', date: '2026-01-22' }, + { medId: 'magnesium', store: 'matsumoto', price: 1680, pkg: 120, unit: 'capsule', date: '2026-02-14' }, + { medId: 'vitamin-d', store: 'amazon-jp', price: 890, pkg: 100, unit: 'capsule', date: '2026-02-10' }, + { medId: 'vitamin-a', store: 'amazon-jp', price: 1240, pkg: 250, unit: 'capsule', date: '2026-01-08' }, +]; + +// Purchases +const PURCHASES = [ + { id: 'p1', store: 'osakado', date: '2026-04-02', status: 'pending', items: [{ medId: 'tadalafil', qty: 28, unit: 'tablet', price: 4537 }] }, + { id: 'p2', store: 'osakado', date: '2026-04-02', status: 'received', items: [{ medId: 'semaglutide', qty: 30, unit: 'tablet', price: 24862 }] }, + { id: 'p3', store: 'amazon-jp', date: '2026-03-22', status: 'received', items: [{ medId: 'magnesium', qty: 120, unit: 'capsule', price: 1480 }, { medId: 'vitamin-d', qty: 100, unit: 'capsule', price: 890 }] }, + { id: 'p4', store: 'osakado', date: '2026-03-20', status: 'received', items: [{ medId: 'dutasteride', qty: 30, unit: 'tablet', price: 3860 }, { medId: 'spironolactone', qty: 100, unit: 'tablet', price: 2190 }] }, + { id: 'p5', store: 'osakado', date: '2026-02-28', status: 'received', items: [{ medId: 'estradiol', qty: 5, unit: 'vial', price: 9800 }] }, +]; + +// Cabinet activity events (consumed / adjusted / added) +const ACTIVITY = [ + { id: 'a1', type: 'consumed', medId: 'vitamin-d', delta: -7, before: 103, after: 96, at: '2026-04-03 08:52' }, + { id: 'a2', type: 'consumed', medId: 'magnesium', delta: -7, before: 122, after: 115, at: '2026-04-03 08:52' }, + { id: 'a3', type: 'adjusted', medId: 'estradiol', delta: -1, before: 3, after: 2, at: '2026-04-03 08:57' }, + { id: 'a4', type: 'consumed', medId: 'vitamin-a', delta: -7, before: 218, after: 211, at: '2026-04-03 08:52' }, + { id: 'a5', type: 'consumed', medId: 'spironolactone', delta: -14, before: 76, after: 62, at: '2026-04-03 08:52' }, + { id: 'a6', type: 'added', medId: 'tadalafil', delta: +30, before: -23, after: 7, at: '2026-04-02 14:10', ref: 'Purchase p2' }, + { id: 'a7', type: 'consumed', medId: 'semaglutide', delta: -1, before: 27, after: 26, at: '2026-04-01 08:15' }, + { id: 'a8', type: 'consumed', medId: 'progesterone', delta: -1, before: 36, after: 35, at: '2026-04-03 21:04' }, +]; + +// Monthly spending (last 6 months), JPY +const SPENDING = [ + { month: '2025-11', amount: 28400 }, + { month: '2025-12', amount: 41200 }, + { month: '2026-01', amount: 19600 }, + { month: '2026-02', amount: 38480 }, + { month: '2026-03', amount: 35780 }, + { month: '2026-04', amount: 9937 }, +]; + +window.MESHI = { + MEDICINES, MED, CABINET, REGIMENS, TODAY_INTAKE, + STORES, PRICES, PURCHASES, ACTIVITY, SPENDING, +}; diff --git a/docs/design/styles/cabinet.css b/docs/design/styles/cabinet.css new file mode 100644 index 0000000..64d437c --- /dev/null +++ b/docs/design/styles/cabinet.css @@ -0,0 +1,473 @@ +/* Cabinet — hero screen */ + +.cab-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow: hidden; +} +.cab-summary__item { + background: var(--bg-elev); + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 3px; +} +.cab-summary__label { + font-size: 11px; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; +} +.cab-summary__value { + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink-strong); + line-height: 1.15; +} +.cab-summary__item--danger .cab-summary__value { color: var(--danger); } +.cab-summary__item--warn .cab-summary__value { color: var(--warn); } +.cab-summary__item--brand .cab-summary__value { color: var(--brand); } +.cab-summary__hint { font-size: 11px; color: var(--ink-faint); margin-top: 2px; } + +/* Filters */ +.cab-filters { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} +.cab-filters__tabs { + display: flex; + gap: 4px; + background: var(--bg-elev); + padding: 4px; + border-radius: var(--r-sm); + border: 1px solid var(--border); +} +.cab-filters__tab { + padding: 6px 12px; + font-size: 12px; + color: var(--ink-muted); + border-radius: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.12s; +} +.cab-filters__tab:hover { color: var(--ink); } +.cab-filters__tab.is-active { + background: var(--ink); + color: var(--bg-elev); + font-weight: 500; +} +.cab-filters__count { + font-size: 10px; + padding: 1px 5px; + border-radius: 6px; + background: var(--bg-inset); + color: var(--ink-muted); +} +.cab-filters__tab.is-active .cab-filters__count { + background: rgba(255,255,255,0.12); + color: var(--bg-elev); +} +.cab-filters__tools { + display: flex; + gap: 8px; + align-items: center; +} +.cab-filters__search { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 6px 10px; + color: var(--ink-muted); +} +.cab-filters__search input { + border: 0; outline: 0; background: transparent; width: 120px; font-size: 12px; +} +.cab-filters__search input::placeholder { color: var(--ink-faint); } + +/* Grid */ +.cab-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} +.cab-card { + text-align: left; + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-md); + transition: all 0.15s; + position: relative; + overflow: hidden; +} +.cab-card:hover { + border-color: var(--border-strong); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} +.cab-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 3px; + background: var(--viz-1); + opacity: 0; + transition: opacity 0.15s; +} +.cab-card:hover::before { opacity: 0.6; } +.cab-card--lvl-low::before, .cab-card--lvl-critical::before { + background: var(--danger); opacity: 1; +} +.cab-card--lvl-expiring::before { background: var(--warn); opacity: 1; } + +.cab-card__top { + display: flex; + align-items: center; + gap: 10px; +} +.cab-card__swatch { + width: 34px; height: 34px; + border-radius: var(--r-sm); + display: grid; place-items: center; + background: color-mix(in oklab, var(--c) 16%, var(--bg-elev)); + color: var(--c); + flex-shrink: 0; +} +.cab-card__swatch--sm { width: 22px; height: 22px; border-radius: 6px; } +.cab-card__meta { flex: 1; min-width: 0; } +.cab-card__name { + font-size: 14px; + font-weight: 500; + color: var(--ink-strong); + letter-spacing: -0.01em; +} +.cab-card__strength { + font-size: 11px; + color: var(--ink-muted); + margin-top: 1px; +} +.cab-card__flag { flex-shrink: 0; } + +.cab-card__qty { + display: flex; + align-items: baseline; + gap: 6px; +} +.cab-card__qtyNum { + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink-strong); + line-height: 1; +} +.cab-card__qtyUnit { + font-size: 12px; + color: var(--ink-muted); +} + +.cab-card__foot { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + padding-top: 12px; + border-top: 1px dashed var(--border); +} +.cab-card__footLabel { + font-size: 10px; + color: var(--ink-faint); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; +} +.cab-card__footValue { + font-size: 12px; + color: var(--ink); + font-weight: 500; + margin-top: 2px; +} +.cab-card__footValue.is-warn { color: var(--warn); } + +/* Days bar */ +.daysbar { + display: flex; + align-items: center; + gap: 10px; +} +.daysbar__track { + flex: 1; + height: 6px; + background: var(--bg-inset); + border-radius: 3px; + position: relative; + overflow: hidden; +} +.daysbar__fill { + height: 100%; + background: var(--ok); + border-radius: 3px; + transition: width 0.3s; +} +.daysbar--low .daysbar__fill { background: var(--warn); } +.daysbar--critical .daysbar__fill { background: var(--danger); } +.daysbar__mark { + position: absolute; + top: -2px; bottom: -2px; + width: 1px; + background: var(--ink-faint); + opacity: 0.5; +} +.daysbar__label { + font-size: 11px; + display: flex; + align-items: baseline; + gap: 3px; + min-width: 50px; + justify-content: flex-end; +} +.daysbar__num { + font-weight: 600; + color: var(--ink); + font-size: 13px; +} +.daysbar__unit { color: var(--ink-muted); font-size: 10px; } +.daysbar--low .daysbar__num { color: var(--warn); } +.daysbar--critical .daysbar__num { color: var(--danger); } + +/* Table view */ +.cab-table { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow: hidden; +} +.cab-table__head, .cab-table__row { + display: grid; + grid-template-columns: 2fr 0.7fr 1.4fr 1fr 0.8fr 0.8fr; + gap: 14px; + padding: 10px 18px; + align-items: center; + font-size: 12px; + text-align: left; + width: 100%; +} +.cab-table__head { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-faint); + background: var(--bg-inset); + border-bottom: 1px solid var(--border); + font-weight: 600; +} +.cab-table__row { + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.cab-table__row:last-child { border-bottom: 0; } +.cab-table__row:hover { background: var(--bg-inset); } +.cab-table__cellMed { + display: flex; align-items: center; gap: 10px; +} + +/* Shelf view */ +.cab-shelf { + display: flex; + flex-direction: column; + gap: 40px; + padding: 20px 0 40px; +} +.cab-shelf__level { + position: relative; +} +.cab-shelf__tag { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-faint); + font-weight: 600; + margin-bottom: 12px; +} +.cab-shelf__bottles { + display: flex; + gap: 20px; + align-items: flex-end; + min-height: 160px; + padding: 0 4px; +} +.cab-shelf__line { + height: 3px; + background: linear-gradient(180deg, var(--border-strong), var(--border)); + border-radius: 1px; + margin-top: 8px; + box-shadow: 0 1px 0 var(--bg-inset); +} + +.cab-bottle { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + transition: transform 0.15s; + width: 80px; +} +.cab-bottle:hover { transform: translateY(-3px); } +.cab-bottle__body { + width: 46px; + height: 110px; + background: color-mix(in oklab, var(--c) 18%, var(--bg-elev)); + border: 1.5px solid color-mix(in oklab, var(--c) 50%, var(--bg-elev)); + border-radius: 8px 8px 6px 6px; + position: relative; + overflow: hidden; +} +.cab-bottle__fill { + position: absolute; + left: 0; right: 0; bottom: 0; + background: var(--c); + transition: height 0.4s; +} +.cab-bottle__cap { + position: absolute; + top: -6px; left: 50%; transform: translateX(-50%); + width: 28px; height: 8px; + background: var(--ink-muted); + border-radius: 3px 3px 2px 2px; +} +.cab-bottle--lvl-low .cab-bottle__body, .cab-bottle--lvl-critical .cab-bottle__body { + border-color: var(--danger); +} +.cab-bottle__label { text-align: center; } +.cab-bottle__name { + font-size: 11px; + font-weight: 500; + color: var(--ink); + max-width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cab-bottle__qty { font-size: 10px; color: var(--ink-muted); } + +/* Detail drawer */ +.cab-detail { + position: fixed; + inset: 0; + background: rgba(20, 18, 10, 0.3); + z-index: 50; + display: flex; + justify-content: flex-end; + animation: fade-in 0.2s; +} +@keyframes fade-in { from { opacity: 0; } } +.cab-detail__panel { + width: min(520px, 100%); + background: var(--bg-elev); + border-left: 1px solid var(--border); + box-shadow: var(--shadow-lg); + overflow-y: auto; + animation: slide-in 0.2s; + padding: 24px 28px 32px; +} +@keyframes slide-in { from { transform: translateX(40px); opacity: 0; } } +.cab-detail__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} +.cab-detail__title { + font-family: var(--font-display); + font-size: 22px; + font-weight: 500; + letter-spacing: -0.02em; + color: var(--ink-strong); +} +.cab-detail__sub { + font-size: 12px; + color: var(--ink-muted); + margin-top: 2px; +} +.cab-detail__hero { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; + padding: 16px; + background: var(--bg-inset); + border-radius: var(--r-md); + margin-bottom: 16px; +} +.cab-detail__bigLabel { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-faint); + font-weight: 600; + margin-bottom: 4px; +} +.cab-detail__big { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink-strong); + line-height: 1.1; +} +.cab-detail__big span { + font-size: 11px; + color: var(--ink-muted); + font-weight: 400; + font-family: var(--font-sans); + margin-left: 2px; +} +.cab-detail__big.is-warn { color: var(--warn); } +.cab-detail__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.cab-detail__section { margin-top: 20px; } +.cab-detail__sectionTitle { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); + font-weight: 600; + margin-bottom: 10px; +} +.cab-detail__prices { + display: flex; + flex-direction: column; + gap: 6px; +} +.cab-detail__price { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--bg-elev); +} +.cab-detail__price.is-best { + border-color: var(--ok); + background: var(--ok-soft); +} +.cab-detail__priceStore { flex: 1; font-size: 13px; } +.cab-detail__priceBody { text-align: right; } diff --git a/docs/design/styles/dashboard.css b/docs/design/styles/dashboard.css new file mode 100644 index 0000000..5e559be --- /dev/null +++ b/docs/design/styles/dashboard.css @@ -0,0 +1,152 @@ +/* Dashboard */ +.dash-hero { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + padding: 4px 4px 24px; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} +.dash-hero__day { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); + font-weight: 500; + margin-bottom: 6px; +} +.dash-hero__title { + font-family: var(--font-display); + font-size: 34px; + font-weight: 400; + letter-spacing: -0.02em; + color: var(--ink-strong); + line-height: 1.05; +} +.dash-hero__sub { + font-size: 14px; + color: var(--ink-muted); + margin-top: 8px; + max-width: 520px; +} + +.dash-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 16px; +} +.dash-panel { overflow: hidden; } + +/* Schedule */ +.dash-schedule { padding: 4px 18px 18px; } +.dash-sched__slot { + display: grid; + grid-template-columns: 80px 1fr; + gap: 16px; + padding: 14px 0; + border-bottom: 1px dashed var(--border); + align-items: flex-start; +} +.dash-sched__slot:last-child { border-bottom: 0; } +.dash-sched__slot.is-done { opacity: 0.55; } +.dash-sched__time { padding-top: 4px; } +.dash-sched__items { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.dash-sched__pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px 6px 8px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + transition: all 0.12s; +} +.dash-sched__pill:hover { border-color: var(--border-strong); } +.dash-sched__pill.is-taken { background: var(--ok-soft); border-color: transparent; } +.dash-sched__pill.is-taken .dash-sched__name { text-decoration: line-through; color: var(--ink-muted); } +.dash-sched__check { + width: 16px; height: 16px; + border-radius: 50%; + border: 1.5px solid var(--border-strong); + display: grid; place-items: center; + color: transparent; + transition: all 0.15s; +} +.dash-sched__check.is-taken { background: var(--ok); border-color: var(--ok); color: white; } +.dash-sched__name { font-weight: 500; color: var(--ink-strong); } +.dash-sched__dose { color: var(--ink-muted); font-size: 11px; } + +/* Low */ +.dash-low { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; } +.dash-low__item { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 0; + border-bottom: 1px dashed var(--border); +} +.dash-low__item:last-child { border-bottom: 0; } + +/* Spend */ +.dash-spend { padding: 12px 18px 18px; } + +/* Supply */ +.dash-supply { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 6px; } +.dash-supply__row { + display: grid; + grid-template-columns: 140px 1fr 48px; + gap: 12px; + align-items: center; + font-size: 12px; + padding: 4px 0; +} +.dash-supply__name { font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dash-supply__bar { + height: 8px; + background: var(--bg-inset); + border-radius: 2px; + overflow: hidden; + position: relative; +} +.dash-supply__fill { height: 100%; border-radius: 2px; transition: width 0.3s; } +.dash-supply__fill--ok { background: var(--brand); } +.dash-supply__fill--low { background: var(--warn); } +.dash-supply__fill--critical { background: var(--danger); } +.dash-supply__days { font-weight: 600; text-align: right; } +.dash-supply__days span { color: var(--ink-faint); font-weight: 400; margin-left: 1px; font-size: 10px; } + +/* Orders */ +.dash-orders { padding: 4px 18px 16px; display: flex; flex-direction: column; gap: 4px; } +.dash-order { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px dashed var(--border); +} +.dash-order:last-child { border-bottom: 0; } +.dash-order__icon { + width: 28px; height: 28px; + border-radius: 50%; + background: var(--brand-soft); + color: var(--brand-soft-ink); + display: grid; place-items: center; +} +.dash-order.is-past .dash-order__icon { background: var(--bg-inset); color: var(--ink-muted); } + +/* Activity */ +.dash-activity { padding: 4px 18px 16px; } +.dash-act { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 0; + border-bottom: 1px dashed var(--border); +} +.dash-act:last-child { border-bottom: 0; } diff --git a/docs/design/styles/other.css b/docs/design/styles/other.css new file mode 100644 index 0000000..e46b681 --- /dev/null +++ b/docs/design/styles/other.css @@ -0,0 +1,199 @@ +/* Other pages */ +.sub-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; } +.form-label { display:block; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-muted); font-weight: 600; margin-bottom: 6px; } +.form-select { + padding: 8px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 13px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} +.lib-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); +} +.lib-row:last-child { border-bottom: 0; } +.lib-row:hover { background: var(--bg-inset); } + +.org-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; } +.org-day { padding: 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--r-md); } +.org-day__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; } +.org-day__slots { display: flex; flex-wrap: wrap; gap: 4px; min-height: 70px; } +.org-pill { + width: 14px; height: 20px; background: var(--c); border-radius: 7px; + box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1), inset 0 3px 0 rgba(255,255,255,0.2); + display: inline-block; +} +.org-day__count { font-size: 10px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .06em; font-weight:600; margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); } + +/* Organizer — fill + inventory impact */ +.org-hero { + display: grid; + grid-template-columns: 1fr 1.3fr; + gap: 0; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow: hidden; +} +.org-hero__controls { + padding: 20px 22px; + background: var(--bg-inset); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 18px; +} +.org-hero__divider { height: 1px; background: var(--border); } +.org-hero__select { + display: block; + margin-top: 6px; + padding: 8px 12px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 14px; + font-weight: 500; + color: var(--ink-strong); + width: 100%; + font-family: inherit; +} +.org-hero__stepper { + display: inline-flex; + align-items: center; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-sm); + overflow: hidden; +} +.org-hero__stepper button { + width: 32px; height: 36px; + border: none; background: transparent; + font-size: 16px; color: var(--ink-strong); + cursor: pointer; +} +.org-hero__stepper button:hover { background: var(--bg-inset); } +.org-hero__stepper span { + padding: 0 14px; font-size: 20px; font-weight: 600; min-width: 48px; text-align: center; + color: var(--ink-strong); + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + height: 36px; + line-height: 36px; +} +.org-hero__presets { display: flex; gap: 4px; margin-top: 8px; } +.org-hero__preset { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: transparent; + font-size: 11px; + color: var(--ink-muted); + cursor: pointer; + font-family: inherit; +} +.org-hero__preset.is-active { background: var(--ink); color: var(--bg-elev); border-color: var(--ink); } + +.org-hero__summary { + padding: 22px; + display: flex; + flex-direction: column; + gap: 16px; +} +.org-alert { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: var(--r-sm); + font-size: 13px; +} +.org-alert strong { font-weight: 600; color: var(--ink-strong); } +.org-alert > div:first-of-type { flex: 1; } +.org-alert--ok { background: var(--ok-soft); color: var(--ok); } +.org-alert--ok strong { color: var(--ok); } +.org-alert--warn { background: var(--warn-soft); color: var(--warn); } +.org-alert--warn strong { color: var(--warn); } +.org-alert--danger { background: var(--danger-soft); color: var(--danger); } +.org-alert--danger strong { color: var(--danger); } + +.org-impact { padding: 4px 18px 16px; } +.org-impact__head, .org-impact__row { + display: grid; + grid-template-columns: 2.4fr 0.8fr 0.8fr 1.6fr 0.8fr; + gap: 14px; + padding: 10px 8px; + font-size: 13px; + align-items: center; +} +.org-impact__head { + font-size: 10px; text-transform: uppercase; letter-spacing: .06em; + color: var(--ink-faint); font-weight: 600; + border-bottom: 1px solid var(--border); +} +.org-impact__row { border-bottom: 1px dashed var(--border); } +.org-impact__row:last-child { border-bottom: 0; } +.org-impact__row--short { background: color-mix(in oklab, var(--danger) 5%, transparent); } +.org-impact__bar { + flex: 1; + position: relative; + height: 6px; + border-radius: 3px; + overflow: hidden; + background: var(--bg-inset); +} +.org-impact__barFill { + position: absolute; top: 0; left: 0; bottom: 0; + border-radius: 3px; + transition: width .2s; +} + +.prices-table { padding: 0 18px 16px; } +.prices-table__head, .prices-table__row { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 14px; + padding: 10px 8px; + font-size: 13px; + align-items: center; +} +.prices-table__head { + font-size: 10px; text-transform: uppercase; letter-spacing: .06em; + color: var(--ink-faint); font-weight: 600; + border-bottom: 1px solid var(--border); +} +.prices-table__row { border-bottom: 1px dashed var(--border); } +.prices-table__row:last-child { border-bottom: 0; } +.prices-table__row.is-best { background: var(--ok-soft); border-radius: 8px; margin: 2px 0; border-bottom: 0; } + +.refill-row { + display: flex; align-items: center; gap: 12px; + padding: 12px 0; border-bottom: 1px dashed var(--border); +} +.refill-row:last-child { border-bottom: 0; } + +.act-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 0; + position: relative; +} +.act-rail { width: 12px; position: relative; align-self: stretch; } +.act-dot { position: absolute; top: 14px; left: 2px; width: 8px; height: 8px; border-radius: 50%; } +.act-dot--consumed { background: var(--info); } +.act-dot--added { background: var(--ok); } +.act-dot--adjusted { background: var(--warn); } + +.settings-row { + display: grid; grid-template-columns: 120px 1fr auto; + gap: 16px; align-items: center; + padding: 10px 0; border-bottom: 1px dashed var(--border); +} +.settings-row:last-child { border-bottom: 0; } +.settings-row > span:first-child { color: var(--ink-muted); font-size: 12px; } diff --git a/docs/design/styles/schedule.css b/docs/design/styles/schedule.css new file mode 100644 index 0000000..889fc88 --- /dev/null +++ b/docs/design/styles/schedule.css @@ -0,0 +1,181 @@ +/* Schedule page — slot-based */ + +/* Week bar: compact adherence heatmap */ +.sched-weekbar { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 12px 16px; + display: flex; + align-items: center; + gap: 20px; +} +.sched-weekbar__days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; + flex: 1; +} +.sched-weekday { + padding: 8px 6px; + border-radius: var(--r-sm); + border: 1px solid transparent; + background: transparent; + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 2px 8px; + align-items: center; + transition: background .12s, border-color .12s; + text-align: left; + cursor: pointer; +} +.sched-weekday:hover { background: var(--bg-inset); } +.sched-weekday.is-active { background: var(--bg-inset); border-color: var(--border-strong); } +.sched-weekday__label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--ink-faint); + font-weight: 600; + grid-row: 1; +} +.sched-weekday__num { + font-size: 14px; + font-weight: 600; + color: var(--ink-strong); + grid-row: 2; +} +.sched-weekday__bar { + grid-column: 2; + grid-row: 1 / 3; + width: 5px; + height: 28px; + background: var(--bg-inset); + border-radius: 2px; + position: relative; + overflow: hidden; +} +.sched-weekday__fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-radius: 2px; + transition: height .2s; +} + +/* Today: slot rows */ +.sched-slots { + padding: 4px 18px 8px; + display: flex; + flex-direction: column; + gap: 8px; +} +.sched-slot { + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--bg-elev); + overflow: hidden; + transition: border-color .12s; +} +.sched-slot:hover { border-color: var(--border-strong); } +.sched-slot.is-open { border-color: var(--border-strong); } +.sched-slot--done { background: color-mix(in oklab, var(--ok) 4%, var(--bg-elev)); } +.sched-slot--overdue { border-color: color-mix(in oklab, var(--warn) 40%, var(--border)); } + +.sched-slot__row { + width: 100%; + display: grid; + grid-template-columns: 100px 1fr auto; + align-items: center; + gap: 16px; + padding: 14px 16px; + background: transparent; + border: none; + text-align: left; + cursor: pointer; +} +.sched-slot__time { display: flex; flex-direction: column; gap: 2px; } +.sched-slot__hour { + font-size: 20px; + font-weight: 600; + color: var(--ink-strong); + letter-spacing: -0.01em; + line-height: 1; +} +.sched-slot__phase { + font-size: 11px; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: .06em; + font-weight: 500; +} +.sched-slot__main { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} +.sched-slot__summary { + display: flex; + align-items: center; + gap: 10px; +} +.sched-slot__dots { + display: flex; + gap: 3px; + margin-left: 4px; +} +.sched-slot__dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid; + background: transparent; + transition: all .15s; +} +.sched-slot__actions { + display: flex; + align-items: center; + gap: 8px; +} +.sched-slot__detail { + border-top: 1px solid var(--border); + padding: 10px 16px 14px 116px; + display: flex; + flex-direction: column; + gap: 4px; + background: var(--bg-inset); +} +.sched-slot__item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-sm); + transition: all 0.12s; + text-align: left; + width: 100%; + cursor: pointer; +} +.sched-slot__item:hover { border-color: var(--border-strong); } +.sched-slot__item.is-taken { opacity: 0.65; } + +.sched-check { + width: 18px; height: 18px; border-radius: 50%; + border: 1.5px solid var(--border-strong); + display: grid; place-items: center; + color: transparent; + flex-shrink: 0; +} +.sched-check.is-taken { background: var(--ok); border-color: var(--ok); color: white; } + +.sched-regimen { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + margin-bottom: 8px; +} diff --git a/docs/design/styles/shell.css b/docs/design/styles/shell.css new file mode 100644 index 0000000..652a58b --- /dev/null +++ b/docs/design/styles/shell.css @@ -0,0 +1,370 @@ +/* Shell: sidebar + topbar + layout */ + +.mt-app { + display: grid; + grid-template-columns: 248px 1fr; + min-height: 100vh; + background: var(--bg); +} + +/* Sidebar */ +.mt-side { + background: var(--bg-elev); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; +} +.mt-side__brand { + display: flex; + gap: 10px; + align-items: center; + padding: 18px 18px 14px; + border-bottom: 1px solid var(--border); +} +.mt-side__logo { + width: 34px; height: 34px; + border-radius: 10px; + display: grid; place-items: center; + background: var(--brand-soft); +} +.mt-side__title { + font-family: var(--font-display); + font-size: 17px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink-strong); +} +.mt-side__sub { + font-size: 11px; + color: var(--ink-muted); + font-variant-numeric: tabular-nums; +} +.mt-side__nav { + flex: 1; + overflow-y: auto; + padding: 10px 10px 16px; +} +.mt-side__group { margin-bottom: 14px; } +.mt-side__section { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-faint); + padding: 10px 10px 6px; +} +.mt-side__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 10px; + border-radius: var(--r-sm); + font-size: 13px; + color: var(--ink-muted); + text-align: left; + transition: background 0.1s, color 0.1s; + white-space: nowrap; +} +.mt-side__item > span:not(.mt-side__badge) { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mt-side__item { min-width: 0; } +.mt-side__item:hover { background: var(--bg-inset); color: var(--ink); } +.mt-side__item.is-active { + background: var(--brand-soft); + color: var(--brand-soft-ink); + font-weight: 500; +} +.mt-side__item svg { opacity: 0.8; } +.mt-side__item.is-active svg { opacity: 1; } +.mt-side__soon { + margin-left: auto; + font-size: 9px; + font-weight: 600; + color: var(--ink-faint); + background: var(--bg-inset); + padding: 1px 6px; + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.mt-side__item.is-soon { opacity: 0.55; cursor: not-allowed; } +.mt-side__item.is-soon:hover { background: transparent; color: var(--ink-muted); } +.mt-side__badge { + margin-left: auto; + background: var(--danger-soft); + color: var(--danger); + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + min-width: 18px; + text-align: center; + font-variant-numeric: tabular-nums; +} +.mt-side__foot { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-top: 1px solid var(--border); +} +.mt-side__avatar { + width: 32px; height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, var(--viz-4), var(--viz-3)); + color: white; + display: grid; place-items: center; + font-weight: 600; + font-size: 13px; +} +.mt-side__whoName { font-size: 13px; font-weight: 500; color: var(--ink-strong); } +.mt-side__whoRole { font-size: 11px; color: var(--ink-muted); } + +/* Topbar */ +.mt-main { display: flex; flex-direction: column; min-width: 0; } +.mt-top { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 22px 32px 18px; + border-bottom: 1px solid var(--border); + background: var(--bg); + position: sticky; + top: 0; + z-index: 5; + backdrop-filter: blur(8px); +} +.mt-top__crumbs { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--ink-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 500; +} +.mt-top__crumbs .is-current { color: var(--ink); } +.mt-top__title { + font-family: var(--font-display); + font-size: 28px; + font-weight: 500; + letter-spacing: -0.02em; + color: var(--ink-strong); + margin: 0; + line-height: 1.1; +} +.mt-top__sub { + font-size: 13px; + color: var(--ink-muted); + margin-top: 4px; +} +.mt-top__right { + display: flex; + align-items: center; + gap: 10px; +} +.mt-top__search { + position: relative; + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 6px 10px; + min-width: 280px; + color: var(--ink-muted); + transition: border-color 0.15s; +} +.mt-top__search:focus-within { border-color: var(--brand); } +.mt-top__search input { + border: 0; outline: 0; background: transparent; + font-size: 13px; flex: 1; color: var(--ink); +} +.mt-top__search input::placeholder { color: var(--ink-faint); } +.mt-top__search kbd { + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 5px; + background: var(--bg-inset); + border-radius: 4px; + border: 1px solid var(--border); + color: var(--ink-muted); +} + +.mt-iconbtn { + width: 32px; height: 32px; + border-radius: var(--r-sm); + display: grid; place-items: center; + background: var(--bg-elev); + border: 1px solid var(--border); + color: var(--ink-muted); + position: relative; + transition: all 0.1s; +} +.mt-iconbtn:hover { color: var(--ink); border-color: var(--border-strong); } +.mt-iconbtn__dot { + position: absolute; + top: 6px; right: 6px; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--danger); + border: 1.5px solid var(--bg-elev); +} + +/* Page container */ +.mt-page { + padding: 28px 32px 56px; + max-width: 1400px; + width: 100%; +} + +/* Buttons */ +.mt-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: var(--r-sm); + font-size: 13px; + font-weight: 500; + transition: all 0.12s; + border: 1px solid transparent; + line-height: 1; + white-space: nowrap; +} +.mt-btn--primary { + background: var(--brand); + color: var(--brand-ink); +} +.mt-btn--primary:hover { background: var(--brand-deep); } +.mt-btn--ghost { + background: var(--bg-elev); + color: var(--ink); + border-color: var(--border); +} +.mt-btn--ghost:hover { border-color: var(--border-strong); background: var(--bg-inset); } +.mt-btn--subtle { + color: var(--ink-muted); + padding: 6px 10px; +} +.mt-btn--subtle:hover { color: var(--ink); background: var(--bg-inset); } +.mt-btn--danger { + color: var(--danger); + border-color: var(--danger-soft); + background: var(--bg-elev); +} +.mt-btn--danger:hover { background: var(--danger-soft); } + +/* Card */ +.mt-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--r-md); +} +.mt-card__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px 14px; + border-bottom: 1px solid var(--border); +} +.mt-card__title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 500; + color: var(--ink-strong); + letter-spacing: -0.01em; +} +.mt-card__sub { + font-size: 12px; + color: var(--ink-muted); + margin-top: 2px; +} +.mt-card__body { padding: 16px 18px; } + +/* Pill / badge */ +.mt-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + line-height: 1.6; + letter-spacing: 0.01em; + border: 1px solid transparent; +} +.mt-pill--brand { background: var(--brand-soft); color: var(--brand-soft-ink); } +.mt-pill--ok { background: var(--ok-soft); color: var(--ok); } +.mt-pill--warn { background: var(--warn-soft); color: var(--warn); } +.mt-pill--danger { background: var(--danger-soft); color: var(--danger); } +.mt-pill--info { background: var(--info-soft); color: var(--info); } +.mt-pill--ghost { background: var(--bg-inset); color: var(--ink-muted); } +.mt-pill--outline { border-color: var(--border); color: var(--ink-muted); } + +/* Utility */ +.mt-row { display: flex; gap: 12px; align-items: center; } +.mt-col { display: flex; flex-direction: column; gap: 12px; } +.mt-grid { display: grid; gap: 12px; } +.mt-divider { height: 1px; background: var(--border); margin: 12px 0; } +.mt-stack > * + * { margin-top: 12px; } +.mt-mono { font-family: var(--font-mono); font-size: 12px; } + +/* Tweaks panel */ +.mt-tweaks { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 100; + background: var(--bg-elev); + border: 1px solid var(--border-strong); + border-radius: var(--r-md); + box-shadow: var(--shadow-lg); + padding: 14px; + min-width: 240px; + display: none; +} +.mt-tweaks.is-open { display: block; } +.mt-tweaks__title { + font-family: var(--font-display); + font-size: 13px; + font-weight: 500; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} +.mt-tweaks__row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-top: 1px solid var(--border); + font-size: 12px; +} +.mt-tweaks__row:first-of-type { border-top: 0; } +.mt-seg { + display: inline-flex; + background: var(--bg-inset); + border-radius: 6px; + padding: 2px; + gap: 0; +} +.mt-seg button { + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + color: var(--ink-muted); +} +.mt-seg button.is-active { + background: var(--bg-elev); + color: var(--ink); + box-shadow: var(--shadow-sm); +} diff --git a/docs/design/styles/tokens.css b/docs/design/styles/tokens.css new file mode 100644 index 0000000..973a56f --- /dev/null +++ b/docs/design/styles/tokens.css @@ -0,0 +1,123 @@ +/* MeshiTrack design tokens */ + +:root { + /* Type */ + --font-sans: 'Inter Tight', -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; + --font-display: 'Fraunces', 'Inter Tight', serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace; + + /* Neutrals (warm off-white → near-black) */ + --bg: #f6f4ef; + --bg-elev: #ffffff; + --bg-inset: #efebe3; + --border: #e4dfd4; + --border-strong: #cfc8b8; + --ink-faint: #a39b89; + --ink-muted: #6e6759; + --ink: #1d1b17; + --ink-strong: #0b0a08; + + /* Brand — refined sage-leaning green (was straight green) */ + --brand: #2f6b4a; + --brand-deep: #1e4a32; + --brand-ink: #ffffff; + --brand-soft: #e6efe8; + --brand-soft-ink: #1e4a32; + + /* Status */ + --danger: #b8361a; + --danger-soft: #fbe8e0; + --warn: #a66a0a; + --warn-soft: #f9ecd2; + --ok: #3e7a4c; + --ok-soft: #e3ede0; + --info: #3a5a85; + --info-soft: #e1e8f1; + + /* Data viz (cabinet categories / charts) */ + --viz-1: #2f6b4a; + --viz-2: #a66a0a; + --viz-3: #3a5a85; + --viz-4: #8a4c6e; + --viz-5: #6b6237; + --viz-6: #7a3a28; + + /* Shape */ + --r-xs: 6px; + --r-sm: 10px; + --r-md: 14px; + --r-lg: 20px; + --r-xl: 28px; + + /* Shadow */ + --shadow-sm: 0 1px 2px rgba(20, 18, 10, 0.04), 0 0 0 1px rgba(20, 18, 10, 0.04); + --shadow-md: 0 4px 16px -6px rgba(20, 18, 10, 0.08), 0 0 0 1px rgba(20, 18, 10, 0.05); + --shadow-lg: 0 20px 40px -20px rgba(20, 18, 10, 0.22), 0 0 0 1px rgba(20, 18, 10, 0.06); +} + +[data-theme='dark'] { + --bg: #141310; + --bg-elev: #1c1b17; + --bg-inset: #100f0c; + --border: #2a2823; + --border-strong: #3a372f; + --ink-faint: #6a6559; + --ink-muted: #9a9386; + --ink: #ece8de; + --ink-strong: #f8f5ec; + + --brand: #5fa87b; + --brand-deep: #8fc9a4; + --brand-ink: #0b0a08; + --brand-soft: #1e2c23; + --brand-soft-ink: #8fc9a4; + + --danger: #e37358; + --danger-soft: #2c1a15; + --warn: #d6a152; + --warn-soft: #2c2217; + --ok: #7aba89; + --ok-soft: #1a2820; + --info: #7ea3cf; + --info-soft: #1a2028; + + --viz-1: #5fa87b; + --viz-2: #d6a152; + --viz-3: #7ea3cf; + --viz-4: #c088a5; + --viz-5: #b8ac76; + --viz-6: #d88a74; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04); + --shadow-md: 0 4px 16px -6px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); + --shadow-lg: 0 20px 40px -20px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-sans); + font-feature-settings: 'ss01', 'cv11'; + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 14px; + line-height: 1.45; + letter-spacing: -0.005em; + transition: background-color 0.2s, color 0.2s; +} +button { font: inherit; color: inherit; cursor: pointer; border: 0; background: none; padding: 0; } +input, select, textarea { font: inherit; color: inherit; } +a { color: inherit; text-decoration: none; } + +::selection { background: var(--brand); color: var(--brand-ink); } + +/* Scrollbars */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 2px solid var(--bg); } + +.mono { font-family: var(--font-mono); } +.serif { font-family: var(--font-display); } +.num { font-variant-numeric: tabular-nums; } diff --git a/docs/instructions/testing.md b/docs/instructions/testing.md index 841a961..e728053 100644 --- a/docs/instructions/testing.md +++ b/docs/instructions/testing.md @@ -472,7 +472,7 @@ All packages enforce coverage thresholds via `vitest.config.ts`. CI will fail if | ----------------- | ----- | --------- | -------- | ---------- | | `packages/api` | 100% | 100% | 90% | 100% | | `packages/shared` | 100% | 100% | 90% | 100% | -| `packages/web` | TBD | TBD | TBD | TBD | +| `packages/web` | 15% | 14% | 13% | 15% | ### Coverage Provider diff --git a/docs/phases/phase-4-pharmacies-prices-refills.md b/docs/phases/phase-4-pharmacies-prices-refills.md index 7c8c256..dfbac63 100644 --- a/docs/phases/phase-4-pharmacies-prices-refills.md +++ b/docs/phases/phase-4-pharmacies-prices-refills.md @@ -1,19 +1,30 @@ -# Phase 4 — Pharmacies, Prices & Refills +# Phase 4 — Pharmacies, Prices, Purchases & Refills -**Goal**: Track where you buy medicines, compare prices across pharmacies, and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9). +**Goal**: Track store catalog prices for medicines, record purchases (which may mix medicines and food), and get automatic refill alerts when cabinet stock is running low. The Store and PriceRecord infrastructure built here is shared with food tracking (Phase 9). **Depends on**: Phase 0, Phase 1 (medicines), Phase 2 (cabinet), Phase 3 (regimens — for burn rate) --- -## Deliverables +## Domain Model -1. `Store` MongoDB schema and CRUD API (shared infrastructure) -2. `PriceRecord` schema for medicine price tracking -3. Price history and store comparison -4. Refill alerts based on burn rate -5. Refill list generation -6. Web UI: stores, price history, refill management +### Price Records vs Purchases + +These are two distinct concepts: + +- **Price Record** — a catalog observation: "Product X at Store Y is listed at $Z for a pack of N units." Nothing moves. Multiple records can exist for the same product+store (different pack sizes, bulk deals, insurance tier, etc.). This is price surveillance. +- **Purchase** — recording that you actually acquired something. Can contain multiple line items mixing medicines and (Phase 9) food products from the same store. + +### Purchase Status + +Most purchases happen at a physical store and items are immediately in hand. Online purchases may take time to arrive. The status reflects this: + +- **`in_cabinet`** — the default for physical store purchases. Items are available immediately and are added to the cabinet right away. +- **`ordered`** — used only for online purchases where the items have not yet arrived. Items in this state are counted toward pending stock in refill alerts (so the user is not repeatedly alerted to reorder something already purchased and on its way). Once items arrive, the purchase is moved to `in_cabinet` and items are added to the cabinet. + +For food (Phase 9), purchases have no intermediate state — food items always go directly to pantry/fridge upon recording. + +Refill alerts must account for both cabinet stock and purchases with status `ordered` so the alert reflects actual available stock. --- @@ -28,13 +39,10 @@ export interface Store { householdId: string; name: string; address?: string; - location?: { - lat: number; - lng: number; - }; + location?: { lat: number; lng: number }; url?: string; notes?: string; - tags: string[]; // e.g., 'pharmacy', 'grocery', 'online', 'bulk', 'discount' + tags: string[]; // e.g. 'pharmacy', 'grocery', 'online', 'bulk', 'discount' isActive: boolean; createdBy: string; createdAt: Date; @@ -49,25 +57,62 @@ export interface Store { export interface MedicinePriceRecord { id: string; householdId: string; - medicineProductId: string; // Reference to MedicineProduct (purchasable level) - medicineProductBrand: string; // Denormalized - medicineId: string; // Reference to Medicine (generic level, for cross-brand comparison) - medicineName: string; // Denormalized + medicineProductId: string; // specific brand/package + medicineProductBrand: string; // denormalized + medicineId: string; // generic medicine, for cross-brand comparison + medicineName: string; // denormalized storeId: string; - storeName: string; // Denormalized + storeName: string; // denormalized price: number; - currency: string; // Default from household settings - quantity: number; // How many pills/units for this price (package size) + currency: string; + quantity: number; // units in this price point (package size) unit: DosageUnit; - pricePerUnit: number; // Computed: price / quantity - date: Date; - isInsurancePrice: boolean; // With insurance vs retail - notes?: string; + pricePerUnit: number; // computed: price / quantity + isInsurancePrice: boolean; + notes?: string; // e.g. "bulk deal", "fast shipping tier" + date: Date; // when price was observed createdBy: string; createdAt: Date; } ``` +### Purchase Schema + +```typescript +// packages/shared/src/types/purchase.ts +export type PurchaseStatus = 'ordered' | 'in_cabinet'; + +export interface Purchase { + id: string; + householdId: string; + storeId: string; + storeName: string; // denormalized + status: PurchaseStatus; // 'in_cabinet' for physical; 'ordered' for online until received + items: PurchaseItem[]; + notes?: string; + purchasedAt: Date; // when the purchase was made (or order placed) + receivedAt?: Date; // set when status moves to in_cabinet for online orders + createdBy: string; + createdAt: Date; + updatedAt: Date; +} + +export interface PurchaseItem { + id: string; // stable item id within the purchase + // exactly one of these is set: + medicineProductId?: string; + foodProductId?: string; // reserved for Phase 9 + // denormalized display info: + name: string; // brand name / food product name + quantity: number; // units in package + unit: string; + actualPrice?: number; // what was actually paid (optional) + currency?: string; + priceRecordId?: string; // optionally links to a PriceRecord used as reference + addedToCabinet: boolean; +} +``` + ### RefillAlert (Computed, not stored) ```typescript @@ -77,18 +122,20 @@ export interface RefillAlert { medicineName: string; medicineStrength: number; medicineStrengthUnit: StrengthUnit; - daysUntilEmpty: number; + daysUntilEmpty: number; // based on cabinet stock only + daysUntilEmptyWithOrders: number; // cabinet + pending ordered items dailyConsumption: number; currentStock: number; - suggestedQuantity: number; // Enough for N days (configurable, default 30) - lastKnownPrice?: { + pendingOrderStock: number; // units in orders with status 'ordered' + suggestedQuantity: number; // enough for N days (configurable, default 30) + cheapestOption?: { price: number; pricePerUnit: number; storeName: string; storeId: string; date: Date; }; - cheapestOption?: { + lastKnownPrice?: { price: number; pricePerUnit: number; storeName: string; @@ -142,9 +189,14 @@ export enum RefillListStatus { // MedicinePriceRecord { householdId: 1, medicineProductId: 1, storeId: 1, date: -1 } -{ householdId: 1, medicineId: 1, date: -1 } // Cross-brand comparison +{ householdId: 1, medicineId: 1, date: -1 } { householdId: 1, storeId: 1, date: -1 } +// Purchase +{ householdId: 1, status: 1, purchasedAt: -1 } +{ householdId: 1, storeId: 1 } +{ householdId: 1, 'items.medicineProductId': 1 } + // RefillList { householdId: 1, status: 1 } { householdId: 1, createdAt: -1 } @@ -166,42 +218,55 @@ export enum RefillListStatus { ### MedicinePricesModule -| Method | Path | Description | Auth | -| ------ | -------------------------------------- | --------------------------------- | ------ | -| POST | `/medicine-prices` | Record a price | member | -| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member | -| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member | -| GET | `/medicine-prices/analytics` | Spending analytics | member | +| Method | Path | Description | Auth | +| ------ | -------------------------------------- | --------------------------------------- | ------ | +| POST | `/medicine-prices` | Record a catalog price observation | member | +| GET | `/medicine-prices/history/:medicineId` | Price history for a medicine | member | +| GET | `/medicine-prices/compare/:medicineId` | Compare stores for a medicine | member | +| GET | `/medicine-prices/analytics` | Price trend analytics | member | + +### PurchasesModule + +| Method | Path | Description | Auth | +| ------ | ------------------------------- | --------------------------------------------------------- | ------ | +| POST | `/purchases` | Record a purchase (physical → immediately in_cabinet; online → ordered) | member | +| GET | `/purchases` | List purchases (filterable by status) | member | +| GET | `/purchases/:id` | Get purchase detail | member | +| PATCH | `/purchases/:id` | Update purchase (notes, items) | member | +| POST | `/purchases/:id/receive` | Mark online purchase received — moves items to cabinet | member | +| DELETE | `/purchases/:id` | Delete purchase (only if status is ordered) | member | ### RefillsModule -| Method | Path | Description | Auth | -| ------ | ----------------------------------- | ---------------------------------------- | ------ | -| GET | `/refills/alerts` | Get refill alerts (medicines running low)| member | -| POST | `/refills/lists` | Create refill list (manual or from alerts)| member | -| GET | `/refills/lists` | List refill lists | member | -| GET | `/refills/lists/:id` | Get refill list | member | -| PATCH | `/refills/lists/:id` | Update refill list | member | -| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member | -| POST | `/refills/lists/:id/add-to-cabinet` | Move checked items to cabinet | member | -| GET | `/refills/lists/:id/store-comparison`| Best store for this list | member | +| Method | Path | Description | Auth | +| ------ | ---------------------------------- | ----------------------------------------- | ------ | +| GET | `/refills/alerts` | Get refill alerts (accounts for pending orders) | member | +| POST | `/refills/lists` | Create refill list (manual or from alerts)| member | +| GET | `/refills/lists` | List refill lists | member | +| GET | `/refills/lists/:id` | Get refill list | member | +| PATCH | `/refills/lists/:id` | Update refill list | member | +| PATCH | `/refills/lists/:id/items/:itemId` | Check off / update item | member | ### Query Parameters ``` # GET /stores -?tags=pharmacy # Filter by tags -&search=walgreens # Name search +?tags=pharmacy +&search=walgreens # GET /medicine-prices/history/:medicineId -?storeId=abc123 # Filter by store -&startDate=2026-01-01 # Date range +?storeId=abc123 +&startDate=2026-01-01 &endDate=2026-03-27 &limit=50 +# GET /purchases +?status=ordered +&storeId=abc123 + # GET /refills/alerts -?thresholdDays=7 # Alert when <= N days of stock remain (default: 7) -&userId=abc123 # Filter by user's regimens +?thresholdDays=7 +&userId=abc123 ``` --- @@ -210,9 +275,10 @@ export enum RefillListStatus { ### 4.1 — Shared Types & Validation -- Add store types to `packages/shared/src/types/store.ts` -- Add medicine price types to `packages/shared/src/types/medicine-price.ts` -- Add refill types to `packages/shared/src/types/refill.ts` +- Store types in `packages/shared/src/types/store.ts` +- Medicine price types in `packages/shared/src/types/medicine-price.ts` +- Order types in `packages/shared/src/types/order.ts` +- Refill types in `packages/shared/src/types/refill.ts` - Zod schemas for all create/update operations ### 4.2 — Stores CRUD (Shared Infrastructure) @@ -220,89 +286,85 @@ export enum RefillListStatus { - `packages/api/src/modules/stores/` - Standard CRUD, scoped to `householdId` - Tag-based filtering (pharmacy, grocery, online, etc.) -- This module is used by both medicine and food domains +- Reused by both medicine and food domains ### 4.3 — Medicine Price Service +Records catalog price observations. Multiple records per product+store are allowed (different pack sizes, bulk tiers, insurance pricing). + ```typescript class MedicinePriceService { - /** Record a price, computing pricePerUnit */ - recordPrice(data: CreateMedicinePriceRecord): Promise; - - /** Get price history for a medicine, optionally filtered by store */ - getPriceHistory(medicineId: string, householdId: string, options?: { - storeId?: string; - startDate?: Date; - endDate?: Date; - limit?: number; - }): Promise; - - /** Compare current prices across stores for a medicine */ + recordPrice(data: CreateMedicinePriceRecord, householdId: string): Promise; + getPriceHistory(medicineId: string, householdId: string, options?: { storeId?: string; startDate?: Date; endDate?: Date; limit?: number }): Promise; compareStores(medicineId: string, householdId: string): Promise; - - /** Estimate price based on most recent record */ estimatePrice(medicineId: string, householdId: string, storeId?: string): Promise; - - /** Spending analytics over time */ - getAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise; + getPriceAnalytics(householdId: string, period: 'month' | 'quarter' | 'year'): Promise; } ``` -### 4.4 — Refill Alert Service +Analytics on this module are **price trends**, not spending — they show how prices change over time, not purchases made. + +### 4.4 — Purchases Module + +A purchase records that items were actually acquired. A single purchase can contain any mix of medicine products and (Phase 9) food products from the same store. + +**Record physical purchase** (default flow — store visit): +- Set `status: 'in_cabinet'` at creation time +- Items are added to cabinet immediately as part of the create call +- No separate receive step needed + +**Record online purchase**: +- Set `status: 'ordered'` at creation time +- Items are NOT added to cabinet yet +- Items in `ordered` purchases are counted as pending stock in refill alerts +- When items arrive, call `POST /purchases/:id/receive` to add to cabinet + +**Receive flow** (`POST /purchases/:id/receive`): +1. For each item with `medicineProductId` and `addedToCabinet: false`: + - Create a `CabinetItem` (status: active, purchaseDate: today) + - If `actualPrice` was recorded on the item, create a `MedicinePriceRecord` + - Mark `addedToCabinet: true` +2. Set purchase status to `in_cabinet` +3. Return summary: `{ addedCount, priceRecordsCreated }` + +### 4.5 — Refill Alert Service + +Refill alerts account for both current cabinet stock and pending orders (status `ordered`) so users are not prompted to reorder medicine already on its way. ```typescript class RefillAlertService { /** * For each medicine in the user's active regimens: * 1. Get burn rate from BurnRateService (Phase 3) - * 2. If daysUntilEmpty <= thresholdDays, create alert - * 3. Attach last known price + cheapest store option - * 4. Calculate suggested quantity (enough for configurable days, default 30) + * 2. Sum cabinet stock + units in purchases with status 'ordered' + * 3. If daysUntilEmpty (cabinet only) <= thresholdDays, create alert + * 4. Attach pending purchase stock, cheapest price option, last known price + * 5. Calculate suggested quantity (enough for configurable days, default 30) */ getAlerts(householdId: string, userId: string, thresholdDays?: number): Promise; } ``` -### 4.5 — Refill Lists +### 4.6 — Refill Lists - CRUD for refill lists - `POST /refills/lists` with optional `fromAlerts: true` to auto-populate from current alerts -- Each item can have an estimated price (from price history) and an actual price (entered when purchased) -- Store comparison: for each item, find cheapest store based on recent price records - -### 4.6 — Refill to Cabinet Flow - -- `POST /refills/lists/:id/add-to-cabinet`: - - For each checked item with `addedToCabinet: false`: - - Create a `CabinetItem` (status: active, purchaseDate: today) - - If `actualPrice` was entered, create a `MedicinePriceRecord` - - Mark `addedToCabinet: true` - - Return summary: `{ addedCount, priceRecordsCreated }` +- Each item can carry an estimated price (from price history) and an actual price (entered when purchased) +- Refill lists are planning aids — actual cabinet addition goes through Purchases ### 4.7 — Price Analytics -```typescript -interface MedicineSpendingAnalytics { - /** Total spending per period */ - spendingOverTime: { period: string; total: number }[]; +Analytics on the medicine-prices page show **price trends across stores over time**, not spending: - /** Most expensive medicines */ - topBySpending: { +```typescript +interface PriceAnalytics { + priceOverTime: { period: string; avgPricePerUnit: number; storeName: string }[]; + cheapestByMedicine: { medicineId: string; medicineName: string; - totalSpent: number; - avgPricePerUnit: number; - }[]; - - /** Per-store spending */ - spendingByStore: { - storeId: string; + cheapestPricePerUnit: number; storeName: string; - totalSpent: number; - purchaseCount: number; }[]; - - /** Price trend alerts (significant increases) */ priceAlerts: { medicineId: string; medicineName: string; @@ -314,49 +376,44 @@ interface MedicineSpendingAnalytics { } ``` -### 4.8 — Web UI: Pharmacies & Prices +Spending analytics (total money spent) live on the Orders page, computed from order items with `actualPrice`. -- `/stores` page: - - Store list with CRUD - - Filter by tags (pharmacy, grocery, etc.) - - Per-store summary: total spent, last visit, item count +### 4.8 — Web UI: Stores & Prices + +- `/stores` page: store list with CRUD, filter by tags - `/medicine-prices` page: - - Medicine search -> price history line chart (per store, color-coded) + - Record a price: store → medicine → product → price + quantity + currency + insurance flag + notes + - Price history per medicine: filterable by store, shows price-per-unit over time - Store comparison table for selected medicine - - Spending over time bar chart - - Price alert panel + - Price trend analytics (price changes over time, not spending) -### 4.9 — Web UI: Refill Management +### 4.9 — Web UI: Purchases + +- `/purchases` page: + - List of purchases grouped by status (ordered / in cabinet) + - Record purchase: pick store, choose physical or online, add line items (medicine products) + - Physical purchases: items go to cabinet immediately on save + - Online purchases: sit in `ordered` state until received; "Mark as received" bulk-adds to cabinet + - Shows actual price paid per item (used to auto-create price records on receive) + +### 4.10 — Web UI: Refill Management - `/refills` page: - - **Alerts section**: medicines running low - - Card per medicine: name, days remaining, suggested quantity, cheapest store - - "Generate Refill List" button -> creates list from all alerts - - **Refill lists section**: - - Active lists at top, completed/archived below - - List detail view: - - Items with checkbox, medicine name, quantity, estimated price - - Check off: optionally enter actual price - - Store comparison panel - - "Done Shopping" -> prompts "Add items to cabinet?" -- **Dashboard widget**: refill alert count badge, medicines needing refill soon + - **Alerts section**: medicines running low, with `daysUntilEmpty` and `daysUntilEmptyWithOrders` shown separately so the user can see how much the pending order helps + - **Refill lists section**: planning lists with check-off and estimated prices --- ## Acceptance Criteria - [ ] Can create and manage stores with tags -- [ ] Can record medicine prices and view price history -- [ ] Store comparison shows cheapest option per medicine -- [ ] Refill alerts correctly identify medicines running low based on burn rate -- [ ] Refill lists can be auto-generated from alerts -- [ ] Checked refill items can be added to cabinet in one action -- [ ] Price analytics show spending trends +- [ ] Can record catalog price observations; multiple price points per product+store allowed +- [ ] Store comparison shows cheapest observed price per medicine +- [ ] Can record a purchase (physical or online) mixing medicine and (future) food items +- [ ] Physical purchases add medicine items to cabinet immediately +- [ ] Online purchases sit in ordered state; receiving them adds items to cabinet and optionally records prices +- [ ] Refill alerts correctly factor in pending purchases (status: ordered) when computing days-until-empty +- [ ] Price analytics show price trends, not spending +- [ ] Spending analytics live on the Orders page - [ ] Store infrastructure is reusable for food tracking (Phase 9) - [ ] All queries scoped to `householdId` - ---- - -## Estimated Effort - -Medium-large. Store/price infrastructure, refill alert logic, and the refill-to-cabinet flow involve significant work. The store comparison and analytics add moderate complexity. diff --git a/package-lock.json b/package-lock.json index ff93f4c..8dea230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,13 @@ "typescript-eslint": "^8.57.2" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -31,6 +38,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@auth/core": { "version": "0.41.0", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", @@ -60,6 +108,28 @@ } } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -96,6 +166,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -120,6 +200,159 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -739,6 +972,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -1630,6 +1881,94 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1710,6 +2049,24 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -1906,6 +2263,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -2493,6 +2875,105 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@turbo/darwin-64": { "version": "2.8.20", "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.8.20.tgz", @@ -2588,6 +3069,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2650,6 +3138,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -2947,15 +3442,48 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.1.tgz", - "integrity": "sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.1", + "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2963,14 +3491,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.1", - "vitest": "4.1.1" + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2979,31 +3507,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", - "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", - "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.1", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3024,26 +3552,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", - "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", - "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.1", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -3051,14 +3579,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", - "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3067,9 +3595,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", - "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -3077,15 +3605,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", - "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.1", + "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3188,6 +3716,16 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3211,6 +3749,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3516,6 +4064,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3685,12 +4243,55 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3779,6 +4380,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3786,6 +4408,58 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3867,6 +4541,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3951,6 +4632,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4014,6 +4702,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4037,6 +4732,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4264,6 +4972,16 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -4976,6 +5694,16 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5162,6 +5890,16 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5256,6 +5994,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -5272,6 +6017,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5356,6 +6114,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -5546,6 +6314,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5604,6 +6382,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5630,6 +6415,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5895,6 +6687,85 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6351,6 +7222,16 @@ "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6399,6 +7280,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -6460,6 +7348,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6596,6 +7494,61 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.14", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", + "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6928,6 +7881,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6998,6 +7958,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7041,6 +8014,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7250,6 +8230,41 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -7428,6 +8443,20 @@ "node": ">= 12.13.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7472,6 +8501,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7534,6 +8573,13 @@ "node": ">=10" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7733,6 +8779,19 @@ "node": ">=10" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7985,6 +9044,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -8064,6 +9136,13 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8073,6 +9152,21 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -8171,6 +9265,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8246,6 +9366,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -8262,6 +9389,19 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -8385,6 +9525,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8415,6 +9575,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -8497,6 +9670,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -8632,6 +9821,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -8639,6 +9838,16 @@ "dev": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -8756,19 +9965,19 @@ } }, "node_modules/vitest": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", - "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.1", - "@vitest/mocker": "4.1.1", - "@vitest/pretty-format": "4.1.1", - "@vitest/runner": "4.1.1", - "@vitest/snapshot": "4.1.1", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8779,7 +9988,7 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", + "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, @@ -8796,10 +10005,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.1", - "@vitest/browser-preview": "4.1.1", - "@vitest/browser-webdriverio": "4.1.1", - "@vitest/ui": "4.1.1", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8837,6 +10046,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -8846,6 +10068,16 @@ "node": ">=12" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", @@ -8991,12 +10223,44 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -9006,6 +10270,16 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -9021,6 +10295,35 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9034,6 +10337,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -9102,13 +10418,22 @@ "devDependencies": { "@next/eslint-plugin-next": "^16.2.1", "@tailwindcss/postcss": "^4.2.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^29.0.1", + "msw": "^2.12.14", "tailwindcss": "^4.2.0", - "typescript": "^6.0.0" + "typescript": "^6.0.0", + "vitest": "^4.1.2" } } } diff --git a/packages/api/package.json b/packages/api/package.json index f30a838..f71b4e5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,7 +14,8 @@ "test:cov": "vitest run --coverage", "typecheck": "tsc --noEmit", "clean": "rimraf dist tsconfig.tsbuildinfo", - "seed": "tsx --env-file ../../.env src/scripts/seed.ts" + "seed": "tsx --env-file ../../.env src/scripts/seed.ts", + "migrate:dosage-units": "tsx --env-file ../../.env src/scripts/migrate-dosage-units.ts" }, "dependencies": { "@fastify/awilix": "^8.2.0", diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts index 27ad80e..60386c0 100644 --- a/packages/api/src/main.ts +++ b/packages/api/src/main.ts @@ -34,6 +34,10 @@ import cabinetRoutes from './modules/cabinet/cabinet.routes.js'; import cabinetEventsRoutes from './modules/cabinet-events/cabinet-events.routes.js'; import regimensRoutes from './modules/regimens/regimens.routes.js'; import organizerRoutes from './modules/organizer/organizer.routes.js'; +import storesRoutes from './modules/stores/stores.routes.js'; +import medicinePricesRoutes from './modules/medicine-prices/medicine-prices.routes.js'; +import refillsRoutes from './modules/refills/refills.routes.js'; +import purchasesRoutes from './modules/purchases/purchases.routes.js'; export async function buildApp(opts: { logger?: boolean | object } = {}) { const app = Fastify({ @@ -109,6 +113,10 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) { await app.register(cabinetEventsRoutes); await app.register(regimensRoutes); await app.register(organizerRoutes); + await app.register(storesRoutes); + await app.register(medicinePricesRoutes); + await app.register(refillsRoutes); + await app.register(purchasesRoutes); // Global error handler app.setErrorHandler((error, request, reply) => { diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts b/packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts new file mode 100644 index 0000000..5ed159d --- /dev/null +++ b/packages/api/src/modules/medicine-prices/medicine-prices.repository.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockFind, mockFindOne, mockAggregate, mockSave } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindOne: vi.fn(), + mockAggregate: vi.fn(), + mockSave: vi.fn(), +})); + +vi.mock('../../schemas/medicine-price.schema.js', () => { + const chain = () => ({ + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: mockFind, + }); + const findOneChain = () => ({ + sort: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: mockFindOne, + }); + const aggregateChain = () => ({ exec: mockAggregate }); + + class FakeModel { + data: unknown; + constructor(data: unknown) { this.data = data; } + save = mockSave; + toObject() { return this.data; } + static find = vi.fn(() => chain()); + static findOne = vi.fn(() => findOneChain()); + static aggregate = vi.fn(() => aggregateChain()); + } + return { MedicinePriceModel: FakeModel }; +}); + +import { MedicinePricesRepository } from './medicine-prices.repository.js'; + +describe(MedicinePricesRepository.name, () => { + let repo: MedicinePricesRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new MedicinePricesRepository(); + }); + + describe('create', () => { + it('saves and returns price record', async () => { + const data = { + householdId: 'hh1', + medicineProductId: 'mp-1', + medicineProductBrand: 'Tylenol', + medicineId: 'med-1', + medicineName: 'Acetaminophen', + storeId: 'st-1', + storeName: 'Walgreens', + price: 10, + currency: 'USD', + quantity: 100, + unit: 'tablet', + pricePerUnit: 0.1, + date: new Date(), + isInsurancePrice: false, + createdBy: 'user-1', + }; + mockSave.mockImplementation(function (this: { toObject: () => unknown }) { + return Promise.resolve(this); + }); + + const result = await repo.create(data as never); + + expect(mockSave).toHaveBeenCalled(); + expect(result).toBeTruthy(); + }); + }); + + describe('findByMedicine', () => { + it('returns paginated items', async () => { + const items = [{ _id: 'pr-1', medicineId: 'med-1' }]; + mockFind.mockResolvedValue(items); + + const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 }); + + expect(result.data).toEqual(items); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.cursor).toBeNull(); + }); + + it('sets hasMore when more items exist', async () => { + const items = [{ _id: 'pr-1' }, { _id: 'pr-2' }, { _id: 'pr-3' }]; + mockFind.mockResolvedValue(items); + + const result = await repo.findByMedicine('hh1', 'med-1', { limit: 2 }); + + expect(result.data).toHaveLength(2); + expect(result.pagination.hasMore).toBe(true); + expect(result.pagination.cursor).toBeTruthy(); + }); + + it('handles cursor', async () => { + mockFind.mockResolvedValue([]); + + const cursor = Buffer.from('pr-1').toString('base64'); + const result = await repo.findByMedicine('hh1', 'med-1', { cursor, limit: 20 }); + + expect(result.pagination.hasMore).toBe(false); + }); + + it('returns null cursor when no data', async () => { + mockFind.mockResolvedValue([]); + + const result = await repo.findByMedicine('hh1', 'med-1', { limit: 20 }); + + expect(result.pagination.cursor).toBeNull(); + }); + + it('applies storeId filter', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByMedicine('hh1', 'med-1', { storeId: 'st-1', limit: 20 }); + + expect(mockFind).toHaveBeenCalled(); + }); + + it('applies startDate-only filter', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByMedicine('hh1', 'med-1', { + startDate: '2026-01-01T00:00:00.000Z', + limit: 20, + }); + + expect(mockFind).toHaveBeenCalled(); + }); + + it('applies endDate-only filter', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByMedicine('hh1', 'med-1', { + endDate: '2026-12-31T00:00:00.000Z', + limit: 20, + }); + + expect(mockFind).toHaveBeenCalled(); + }); + }); + + describe('compareStores', () => { + it('returns store comparison results', async () => { + const rows = [ + { + _id: 'st-1', + storeName: 'Walgreens', + latestPrice: 10, + latestPricePerUnit: 0.1, + currency: 'USD', + date: new Date(), + isInsurancePrice: false, + }, + ]; + mockAggregate.mockResolvedValue(rows); + + const result = await repo.compareStores('hh1', 'med-1'); + + expect(result).toHaveLength(1); + expect(result[0].storeId).toBe('st-1'); + expect(result[0].storeName).toBe('Walgreens'); + }); + + it('returns empty array when no records', async () => { + mockAggregate.mockResolvedValue([]); + + const result = await repo.compareStores('hh1', 'med-1'); + + expect(result).toEqual([]); + }); + }); + + describe('getLatestForMedicine', () => { + it('returns latest record', async () => { + const record = { _id: 'pr-1', pricePerUnit: 0.1 }; + mockFindOne.mockResolvedValue(record); + + const result = await repo.getLatestForMedicine('hh1', 'med-1'); + + expect(result).toEqual(record); + }); + + it('filters by storeId when provided', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await repo.getLatestForMedicine('hh1', 'med-1', 'st-1'); + + expect(result).toBeNull(); + }); + + it('returns null when not found', async () => { + mockFindOne.mockResolvedValue(null); + + expect(await repo.getLatestForMedicine('hh1', 'med-1')).toBeNull(); + }); + }); + + describe('getAnalytics', () => { + it('returns analytics object with all fields', async () => { + mockAggregate.mockResolvedValue([]); + + const result = await repo.getAnalytics('hh1', { period: 'month' }); + + expect(result).toHaveProperty('spendingOverTime'); + expect(result).toHaveProperty('topBySpending'); + expect(result).toHaveProperty('spendingByStore'); + expect(result).toHaveProperty('priceAlerts'); + }); + + it('uses quarter date format', async () => { + mockAggregate.mockResolvedValue([]); + + const result = await repo.getAnalytics('hh1', { period: 'quarter' }); + + expect(result).toHaveProperty('spendingOverTime'); + }); + + it('uses year date format', async () => { + mockAggregate.mockResolvedValue([]); + + const result = await repo.getAnalytics('hh1', { period: 'year' }); + + expect(result).toHaveProperty('spendingOverTime'); + }); + + it('handles non-empty analytics results', async () => { + mockAggregate + .mockResolvedValueOnce([{ period: '2026-01', total: 50 }]) + .mockResolvedValueOnce([{ medicineId: 'med-1', medicineName: 'Acetaminophen', totalSpent: 50, avgPricePerUnit: 0.1 }]) + .mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 }]) + .mockResolvedValueOnce([]); + + const result = await repo.getAnalytics('hh1', { period: 'month' }); + + expect(result.spendingOverTime).toHaveLength(1); + expect(result.topBySpending).toHaveLength(1); + expect(result.spendingByStore).toHaveLength(1); + expect(result.priceAlerts).toHaveLength(0); + }); + }); +}); diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts b/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts new file mode 100644 index 0000000..80e2f9c --- /dev/null +++ b/packages/api/src/modules/medicine-prices/medicine-prices.repository.ts @@ -0,0 +1,203 @@ +import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js'; +import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared'; + +export interface CreateMedicinePriceData { + householdId: string; + medicineProductId: string; + medicineProductBrand: string; + medicineId: string; + medicineName: string; + storeId: string; + storeName: string; + price: number; + currency: string; + quantity: number; + unit: string; + pricePerUnit: number; + date: Date; + isInsurancePrice: boolean; + notes?: string; + createdBy: string; +} + +export class MedicinePricesRepository { + public async create(data: CreateMedicinePriceData) { + const record = new MedicinePriceModel(data); + const saved = await record.save(); + return saved.toObject(); + } + + public async findByMedicine( + householdId: string, + medicineId: string, + query: MedicinePriceHistoryQueryInput, + ) { + const filter: Record = { householdId, medicineId }; + + if (query.storeId) filter['storeId'] = query.storeId; + + if (query.startDate || query.endDate) { + const dateFilter: Record = {}; + if (query.startDate) dateFilter['$gte'] = new Date(query.startDate); + if (query.endDate) dateFilter['$lte'] = new Date(query.endDate); + filter['date'] = dateFilter; + } + + if (query.cursor) { + const id = Buffer.from(query.cursor, 'base64').toString(); + filter['_id'] = { $lt: id }; + } + + const limit = query.limit; + const items = await MedicinePriceModel.find(filter) + .sort({ _id: -1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const cursor = + data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null; + + return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } }; + } + + public async compareStores(householdId: string, medicineId: string) { + // Get the most recent price per store for this medicine + const results = await MedicinePriceModel.aggregate([ + { $match: { householdId, medicineId } }, + { $sort: { storeId: 1, date: -1 } }, + { + $group: { + _id: '$storeId', + storeName: { $first: '$storeName' }, + latestPrice: { $first: '$price' }, + latestPricePerUnit: { $first: '$pricePerUnit' }, + currency: { $first: '$currency' }, + date: { $first: '$date' }, + isInsurancePrice: { $first: '$isInsurancePrice' }, + }, + }, + { $sort: { latestPricePerUnit: 1 } }, + ]).exec(); + + return results.map((r) => ({ + storeId: r._id as string, + storeName: r.storeName as string, + latestPrice: r.latestPrice as number, + latestPricePerUnit: r.latestPricePerUnit as number, + currency: r.currency as string, + date: r.date as Date, + isInsurancePrice: r.isInsurancePrice as boolean, + })); + } + + public async getLatestForMedicine( + householdId: string, + medicineId: string, + storeId?: string, + ) { + const filter: Record = { householdId, medicineId }; + if (storeId) filter['storeId'] = storeId; + return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec(); + } + + public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) { + const dateFormat = + query.period === 'month' ? '%Y-%m' : query.period === 'quarter' ? '%Y-Q%q' : '%Y'; + + const [spendingOverTime, topBySpending, spendingByStore] = await Promise.all([ + MedicinePriceModel.aggregate([ + { $match: { householdId } }, + { + $group: { + _id: { $dateToString: { format: dateFormat, date: '$date' } }, + total: { $sum: '$price' }, + }, + }, + { $sort: { _id: 1 } }, + { $project: { _id: 0, period: '$_id', total: 1 } }, + ]).exec(), + + MedicinePriceModel.aggregate([ + { $match: { householdId } }, + { + $group: { + _id: '$medicineId', + medicineName: { $first: '$medicineName' }, + totalSpent: { $sum: '$price' }, + avgPricePerUnit: { $avg: '$pricePerUnit' }, + }, + }, + { $sort: { totalSpent: -1 } }, + { $limit: 10 }, + { $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } }, + ]).exec(), + + MedicinePriceModel.aggregate([ + { $match: { householdId } }, + { + $group: { + _id: '$storeId', + storeName: { $first: '$storeName' }, + totalSpent: { $sum: '$price' }, + purchaseCount: { $sum: 1 }, + }, + }, + { $sort: { totalSpent: -1 } }, + { $project: { _id: 0, storeId: '$_id', storeName: 1, totalSpent: 1, purchaseCount: 1 } }, + ]).exec(), + ]); + + // Price alerts: medicines where the most recent price is >10% higher than the previous + const priceAlerts = await MedicinePriceModel.aggregate([ + { $match: { householdId } }, + { $sort: { medicineId: 1, storeId: 1, date: -1 } }, + { + $group: { + _id: { medicineId: '$medicineId', storeId: '$storeId' }, + medicineName: { $first: '$medicineName' }, + storeName: { $first: '$storeName' }, + prices: { $push: '$pricePerUnit' }, + }, + }, + { $match: { 'prices.1': { $exists: true } } }, + { + $addFields: { + currentPrice: { $arrayElemAt: ['$prices', 0] }, + previousPrice: { $arrayElemAt: ['$prices', 1] }, + }, + }, + { + $addFields: { + changePercent: { + $multiply: [ + { $divide: [{ $subtract: ['$currentPrice', '$previousPrice'] }, '$previousPrice'] }, + 100, + ], + }, + }, + }, + { $match: { changePercent: { $gt: 10 } } }, + { + $project: { + _id: 0, + medicineId: '$_id.medicineId', + medicineName: 1, + storeName: 1, + previousPrice: 1, + currentPrice: 1, + changePercent: 1, + }, + }, + ]).exec(); + + return { + spendingOverTime: spendingOverTime as { period: string; total: number }[], + topBySpending: topBySpending as { medicineId: string; medicineName: string; totalSpent: number; avgPricePerUnit: number }[], + spendingByStore: spendingByStore as { storeId: string; storeName: string; totalSpent: number; purchaseCount: number }[], + priceAlerts: priceAlerts as { medicineId: string; medicineName: string; storeName: string; previousPrice: number; currentPrice: number; changePercent: number }[], + }; + } +} diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts b/packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts new file mode 100644 index 0000000..4982517 --- /dev/null +++ b/packages/api/src/modules/medicine-prices/medicine-prices.routes.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + preferred_username: 'testuser', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: { alg: 'RS256' }, + key: {}, + }), +})); + +const { + mockRecordPrice, + mockGetPriceHistory, + mockCompareStores, + mockGetAnalytics, +} = vi.hoisted(() => ({ + mockRecordPrice: vi.fn(), + mockGetPriceHistory: vi.fn(), + mockCompareStores: vi.fn(), + mockGetAnalytics: vi.fn(), +})); + +vi.mock('./medicine-prices.repository.js', () => ({ + MedicinePricesRepository: class { + create = vi.fn(); + findByMedicine = vi.fn(); + compareStores = vi.fn(); + getLatestForMedicine = vi.fn(); + getAnalytics = vi.fn(); + }, +})); + +vi.mock('./medicine-prices.service.js', () => ({ + MedicinePricesService: class { + recordPrice = mockRecordPrice; + getPriceHistory = mockGetPriceHistory; + compareStores = mockCompareStores; + getAnalytics = mockGetAnalytics; + }, +})); + +vi.mock('../medicine-products/medicine-products.repository.js', () => ({ + MedicineProductsRepository: class { + findById = vi.fn(); + }, +})); + +vi.mock('../stores/stores.repository.js', () => ({ + StoresRepository: class { + findById = vi.fn(); + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import medicinePricesRoutes from './medicine-prices.routes.js'; + +function makeFakePriceRecord(overrides = {}) { + return { + _id: 'pr-1', + householdId: 'hh1', + medicineProductId: 'mp-1', + medicineProductBrand: 'Tylenol', + medicineId: 'med-1', + medicineName: 'Acetaminophen', + storeId: 'st-1', + storeName: 'Walgreens', + price: 10, + currency: 'USD', + quantity: 100, + unit: 'tablet', + pricePerUnit: 0.1, + date: '2026-01-15T00:00:00.000Z', + isInsurancePrice: false, + createdBy: 'kc-1', + createdAt: '2026-01-15T00:00:00.000Z', + ...overrides, + }; +} + +describe('medicine-prices.routes', () => { + let app: Awaited>; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(medicinePricesRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid-token' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + describe('POST /api/v1/households/:householdId/medicine-prices', () => { + const validBody = { + medicineProductId: 'mp-1', + medicineId: 'med-1', + storeId: 'st-1', + price: 10, + currency: 'USD', + quantity: 100, + unit: 'tablet', + isInsurancePrice: false, + }; + + it('records price and returns 201', async () => { + mockRecordPrice.mockResolvedValue(makeFakePriceRecord()); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/medicine-prices', + headers: authHeaders, + payload: validBody, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body._id).toBe('pr-1'); + expect(body.pricePerUnit).toBe(0.1); + }); + + it('passes householdId and userId to service', async () => { + mockRecordPrice.mockResolvedValue(makeFakePriceRecord()); + + await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/medicine-prices', + headers: authHeaders, + payload: validBody, + }); + + expect(mockRecordPrice).toHaveBeenCalledWith( + expect.objectContaining({ medicineProductId: 'mp-1' }), + 'hh1', + 'kc-1', + ); + }); + + it('includes notes in response when present', async () => { + mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ notes: 'insurance price' })); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/medicine-prices', + headers: authHeaders, + payload: validBody, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().notes).toBe('insurance price'); + }); + + it('returns 400 for missing required fields', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/medicine-prices', + headers: authHeaders, + payload: { price: 10 }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('handles Date objects in response', async () => { + mockRecordPrice.mockResolvedValue(makeFakePriceRecord({ + _id: { toString: () => 'pr-obj' }, + date: new Date('2026-01-15T00:00:00.000Z'), + createdAt: new Date('2026-01-15T00:00:00.000Z'), + })); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/medicine-prices', + headers: authHeaders, + payload: validBody, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body._id).toBe('pr-obj'); + expect(body.date).toBe('2026-01-15T00:00:00.000Z'); + }); + }); + + describe('GET /api/v1/households/:householdId/medicine-prices/history/:medicineId', () => { + it('returns paginated price history', async () => { + mockGetPriceHistory.mockResolvedValue({ + data: [makeFakePriceRecord()], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-prices/history/med-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.pagination.hasMore).toBe(false); + }); + + it('passes query params to service', async () => { + mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-prices/history/med-1?storeId=st-1&limit=10', + headers: authHeaders, + }); + + expect(mockGetPriceHistory).toHaveBeenCalledWith( + 'hh1', + 'med-1', + expect.objectContaining({ storeId: 'st-1', limit: 10 }), + ); + }); + }); + + describe('GET /api/v1/households/:householdId/medicine-prices/compare/:medicineId', () => { + it('returns store comparison', async () => { + mockCompareStores.mockResolvedValue([ + { + storeId: 'st-1', + storeName: 'Walgreens', + latestPrice: 10, + latestPricePerUnit: 0.1, + currency: 'USD', + date: new Date('2026-01-15T00:00:00.000Z'), + isInsurancePrice: false, + }, + ]); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-prices/compare/med-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].storeId).toBe('st-1'); + expect(body.data[0].date).toBe('2026-01-15T00:00:00.000Z'); + }); + + it('passes householdId and medicineId to service', async () => { + mockCompareStores.mockResolvedValue([]); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-prices/compare/med-99', + headers: authHeaders, + }); + + expect(mockCompareStores).toHaveBeenCalledWith('hh1', 'med-99'); + }); + }); + + describe('GET /api/v1/households/:householdId/medicine-prices/analytics', () => { + it('returns analytics', async () => { + mockGetAnalytics.mockResolvedValue({ + spendingOverTime: [{ period: '2026-01', total: 50 }], + topBySpending: [], + spendingByStore: [], + priceAlerts: [], + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-prices/analytics', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.spendingOverTime).toHaveLength(1); + expect(body.spendingOverTime[0].total).toBe(50); + }); + + it('passes period query param to service', async () => { + mockGetAnalytics.mockResolvedValue({ + spendingOverTime: [], + topBySpending: [], + spendingByStore: [], + priceAlerts: [], + }); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-prices/analytics?period=year', + headers: authHeaders, + }); + + expect(mockGetAnalytics).toHaveBeenCalledWith( + 'hh1', + expect.objectContaining({ period: 'year' }), + ); + }); + }); +}); diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts b/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts new file mode 100644 index 0000000..ccaac30 --- /dev/null +++ b/packages/api/src/modules/medicine-prices/medicine-prices.routes.ts @@ -0,0 +1,168 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + CreateMedicinePriceRecordSchema, + MedicinePriceHistoryQuerySchema, + MedicinePriceAnalyticsQuerySchema, + MedicinePriceRecordResponseSchema, + MedicinePriceHistoryResponseSchema, + StoreComparisonResponseSchema, + MedicineSpendingAnalyticsResponseSchema, +} from '@meshitrack/shared'; +import { MedicinePricesRepository } from './medicine-prices.repository.js'; +import { MedicinePricesService } from './medicine-prices.service.js'; +import { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js'; +import { StoresRepository } from '../stores/stores.repository.js'; + +type AnyPriceDoc = { + _id: string | { toString: () => string }; + householdId: string; + medicineProductId: string; + medicineProductBrand: string; + medicineId: string; + medicineName: string; + storeId: string; + storeName: string; + price: number; + currency: string; + quantity: number; + unit: string; + pricePerUnit: number; + date: Date | string | { toISOString: () => string }; + isInsurancePrice: boolean; + notes?: string; + createdBy: string; + createdAt: Date | string | { toISOString: () => string }; +}; + +function toIso(v: Date | string | { toISOString: () => string }): string { + if (typeof v === 'string') return v; + return v.toISOString(); +} + +function toPriceRecordResponse(rawDoc: unknown) { + const doc = rawDoc as AnyPriceDoc; + return { + _id: typeof doc._id === 'string' ? doc._id : doc._id.toString(), + householdId: doc.householdId, + medicineProductId: doc.medicineProductId, + medicineProductBrand: doc.medicineProductBrand, + medicineId: doc.medicineId, + medicineName: doc.medicineName, + storeId: doc.storeId, + storeName: doc.storeName, + price: doc.price, + currency: doc.currency, + quantity: doc.quantity, + unit: doc.unit, + pricePerUnit: doc.pricePerUnit, + date: toIso(doc.date), + isInsurancePrice: doc.isInsurancePrice, + ...(doc.notes != null ? { notes: doc.notes } : {}), + createdBy: doc.createdBy, + createdAt: toIso(doc.createdAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + medicinePricesRepository: MedicinePricesRepository; + medicinePricesService: MedicinePricesService; + } +} + +export default fp( + async (fastify) => { + fastify.diContainer.register({ + medicinePricesRepository: asClass(MedicinePricesRepository, { lifetime: Lifetime.SINGLETON }), + medicinePricesService: asClass(MedicinePricesService, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/medicine-prices', + schema: { + params: householdParams, + body: CreateMedicinePriceRecordSchema, + response: { 201: MedicinePriceRecordResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('medicinePricesService'); + const record = await service.recordPrice( + request.body, + request.params.householdId, + request.user.keycloakId, + ); + return reply.status(201).send(toPriceRecordResponse(record)); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/medicine-prices/history/:medicineId', + schema: { + params: householdParams.extend({ medicineId: z.string() }), + querystring: MedicinePriceHistoryQuerySchema, + response: { 200: MedicinePriceHistoryResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('medicinePricesService'); + const result = await service.getPriceHistory( + request.params.householdId, + request.params.medicineId, + request.query, + ); + return reply.send({ + data: result.data.map(toPriceRecordResponse), + pagination: result.pagination, + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/medicine-prices/compare/:medicineId', + schema: { + params: householdParams.extend({ medicineId: z.string() }), + response: { 200: StoreComparisonResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('medicinePricesService'); + const results = await service.compareStores( + request.params.householdId, + request.params.medicineId, + ); + return reply.send({ + data: results.map((r) => ({ + ...r, + date: toIso(r.date), + })), + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/medicine-prices/analytics', + schema: { + params: householdParams, + querystring: MedicinePriceAnalyticsQuerySchema, + response: { 200: MedicineSpendingAnalyticsResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('medicinePricesService'); + const analytics = await service.getAnalytics(request.params.householdId, request.query); + return reply.send(analytics); + }, + }); + }, + { + name: 'medicine-prices-routes', + dependencies: ['auth-plugin'], + }, +); diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts b/packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts new file mode 100644 index 0000000..d53a82c --- /dev/null +++ b/packages/api/src/modules/medicine-prices/medicine-prices.service.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MedicinePricesService } from './medicine-prices.service.js'; + +describe(MedicinePricesService.name, () => { + const mockPricesRepo = { + create: vi.fn(), + findByMedicine: vi.fn(), + compareStores: vi.fn(), + getLatestForMedicine: vi.fn(), + getAnalytics: vi.fn(), + }; + const mockProductsRepo = { + findById: vi.fn(), + }; + const mockStoresRepo = { + findById: vi.fn(), + }; + + let service: MedicinePricesService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new MedicinePricesService({ + medicinePricesRepository: mockPricesRepo as never, + medicineProductsRepository: mockProductsRepo as never, + storesRepository: mockStoresRepo as never, + }); + }); + + describe('recordPrice', () => { + const validInput = { + medicineProductId: 'mp-1', + medicineId: 'med-1', + storeId: 'st-1', + price: 10, + currency: 'USD', + quantity: 100, + unit: 'tablet' as never, + isInsurancePrice: false, + }; + + it('creates price record with computed pricePerUnit', async () => { + mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' }); + mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' }); + const record = { _id: 'pr-1', pricePerUnit: 0.1 }; + mockPricesRepo.create.mockResolvedValue(record); + + const result = await service.recordPrice(validInput, 'hh1', 'user-1'); + + expect(result).toEqual(record); + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ pricePerUnit: 0.1, medicineName: 'Acetaminophen', storeName: 'Walgreens' }), + ); + }); + + it('uses medicineName as brand fallback when brand is not set', async () => { + mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Generic', brand: undefined }); + mockStoresRepo.findById.mockResolvedValue({ name: 'CVS' }); + mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' }); + + await service.recordPrice(validInput, 'hh1', 'user-1'); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ medicineProductBrand: 'Generic' }), + ); + }); + + it('uses provided date when given', async () => { + mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' }); + mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' }); + mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' }); + + await service.recordPrice({ ...validInput, date: '2026-01-15T00:00:00.000Z' }, 'hh1', 'user-1'); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }), + ); + }); + + it('throws NotFoundError when product not found', async () => { + mockProductsRepo.findById.mockResolvedValue(null); + + await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow( + 'Medicine product not found', + ); + }); + + it('throws NotFoundError when store not found', async () => { + mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' }); + mockStoresRepo.findById.mockResolvedValue(null); + + await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow( + 'Store not found', + ); + }); + }); + + describe('getPriceHistory', () => { + it('delegates to repository', async () => { + const result = { data: [], pagination: { cursor: null, hasMore: false } }; + mockPricesRepo.findByMedicine.mockResolvedValue(result); + + const response = await service.getPriceHistory('hh1', 'med-1', { limit: 20 }); + + expect(response).toEqual(result); + expect(mockPricesRepo.findByMedicine).toHaveBeenCalledWith('hh1', 'med-1', { limit: 20 }); + }); + }); + + describe('compareStores', () => { + it('delegates to repository', async () => { + const comparisons = [{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 10 }]; + mockPricesRepo.compareStores.mockResolvedValue(comparisons); + + const result = await service.compareStores('hh1', 'med-1'); + + expect(result).toEqual(comparisons); + expect(mockPricesRepo.compareStores).toHaveBeenCalledWith('hh1', 'med-1'); + }); + }); + + describe('estimatePrice', () => { + it('returns pricePerUnit of latest record', async () => { + mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.15 }); + + const result = await service.estimatePrice('hh1', 'med-1'); + + expect(result).toBe(0.15); + }); + + it('returns null when no records', async () => { + mockPricesRepo.getLatestForMedicine.mockResolvedValue(null); + + const result = await service.estimatePrice('hh1', 'med-1'); + + expect(result).toBeNull(); + }); + + it('filters by storeId when provided', async () => { + mockPricesRepo.getLatestForMedicine.mockResolvedValue({ pricePerUnit: 0.2 }); + + await service.estimatePrice('hh1', 'med-1', 'st-1'); + + expect(mockPricesRepo.getLatestForMedicine).toHaveBeenCalledWith('hh1', 'med-1', 'st-1'); + }); + }); + + describe('getAnalytics', () => { + it('delegates to repository', async () => { + const analytics = { + spendingOverTime: [], + topBySpending: [], + spendingByStore: [], + priceAlerts: [], + }; + mockPricesRepo.getAnalytics.mockResolvedValue(analytics); + + const result = await service.getAnalytics('hh1', { period: 'month' }); + + expect(result).toEqual(analytics); + expect(mockPricesRepo.getAnalytics).toHaveBeenCalledWith('hh1', { period: 'month' }); + }); + }); +}); diff --git a/packages/api/src/modules/medicine-prices/medicine-prices.service.ts b/packages/api/src/modules/medicine-prices/medicine-prices.service.ts new file mode 100644 index 0000000..68a8c56 --- /dev/null +++ b/packages/api/src/modules/medicine-prices/medicine-prices.service.ts @@ -0,0 +1,93 @@ +import type { MedicinePricesRepository } from './medicine-prices.repository.js'; +import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js'; +import type { StoresRepository } from '../stores/stores.repository.js'; +import type { + CreateMedicinePriceRecordInput, + MedicinePriceHistoryQueryInput, + MedicinePriceAnalyticsQueryInput, +} from '@meshitrack/shared'; +import { NotFoundError } from '../../common/errors.js'; + +interface Deps { + medicinePricesRepository: MedicinePricesRepository; + medicineProductsRepository: MedicineProductsRepository; + storesRepository: StoresRepository; +} + +export class MedicinePricesService { + private readonly medicinePricesRepository: MedicinePricesRepository; + private readonly medicineProductsRepository: MedicineProductsRepository; + private readonly storesRepository: StoresRepository; + + public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) { + this.medicinePricesRepository = medicinePricesRepository; + this.medicineProductsRepository = medicineProductsRepository; + this.storesRepository = storesRepository; + } + + public async recordPrice( + data: CreateMedicinePriceRecordInput, + householdId: string, + userId: string, + ) { + const product = await this.medicineProductsRepository.findById( + data.medicineProductId, + householdId, + ); + if (!product) throw new NotFoundError('Medicine product not found'); + + const store = await this.storesRepository.findById(data.storeId, householdId); + if (!store) throw new NotFoundError('Store not found'); + + const pricePerUnit = data.price / data.quantity; + const date = data.date ? new Date(data.date) : new Date(); + + return this.medicinePricesRepository.create({ + householdId, + medicineProductId: data.medicineProductId, + medicineProductBrand: product.brand ?? product.medicineName, + medicineId: data.medicineId, + medicineName: product.medicineName, + storeId: data.storeId, + storeName: store.name, + price: data.price, + currency: data.currency, + quantity: data.quantity, + unit: data.unit, + pricePerUnit, + date, + isInsurancePrice: data.isInsurancePrice, + notes: data.notes, + createdBy: userId, + }); + } + + public async getPriceHistory( + householdId: string, + medicineId: string, + query: MedicinePriceHistoryQueryInput, + ) { + return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query); + } + + public async compareStores(householdId: string, medicineId: string) { + return this.medicinePricesRepository.compareStores(householdId, medicineId); + } + + public async estimatePrice( + householdId: string, + medicineId: string, + storeId?: string, + ): Promise { + const record = await this.medicinePricesRepository.getLatestForMedicine( + householdId, + medicineId, + storeId, + ); + return record ? (record.pricePerUnit as number) : null; + } + + public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) { + return this.medicinePricesRepository.getAnalytics(householdId, query); + } +} diff --git a/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts b/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts index 879c199..83f9239 100644 --- a/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts +++ b/packages/api/src/modules/medicine-products/medicine-products.routes.test.ts @@ -177,6 +177,20 @@ describe('medicine-products.routes', () => { expect(res.statusCode).toBe(200); expect(res.json().brand).toBe('CVS Health'); }); + + it('includes concentration fields in response when present', async () => { + mockFindById.mockResolvedValue(makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' })); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/medicine-products/mp-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().concentration).toBe(5); + expect(res.json().concentrationUnit).toBe('mg/ml'); + }); }); describe('POST /api/v1/households/:householdId/medicines/:medicineId/products', () => { diff --git a/packages/api/src/modules/medicines/medicines.service.test.ts b/packages/api/src/modules/medicines/medicines.service.test.ts index ac7f51b..e1ad191 100644 --- a/packages/api/src/modules/medicines/medicines.service.test.ts +++ b/packages/api/src/modules/medicines/medicines.service.test.ts @@ -135,6 +135,30 @@ describe(MedicinesService.name, () => { ); }); + it('uses current name when name not provided in update', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'med-1', + name: 'Aspirin', + strength: 500, + strengthUnit: StrengthUnit.MG, + form: MedicineForm.TABLET, + }); + mockRepo.findDuplicate.mockResolvedValue(null); + mockRepo.update.mockResolvedValue({ _id: 'med-1', name: 'Aspirin', strength: 250 }); + + const result = await service.update('med-1', 'hh1', { strength: 250 }); + + expect(result).toBeTruthy(); + expect(mockRepo.findDuplicate).toHaveBeenCalledWith( + 'hh1', + 'Aspirin', + 250, + StrengthUnit.MG, + MedicineForm.TABLET, + 'med-1', + ); + }); + it('skips dedup check when no identity fields change', async () => { mockRepo.findById.mockResolvedValue({ _id: 'med-1', name: 'Aspirin' }); mockRepo.update.mockResolvedValue({ _id: 'med-1', notes: 'Updated notes' }); diff --git a/packages/api/src/modules/purchases/purchases.repository.test.ts b/packages/api/src/modules/purchases/purchases.repository.test.ts new file mode 100644 index 0000000..c57dfef --- /dev/null +++ b/packages/api/src/modules/purchases/purchases.repository.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockFind, mockFindOne, mockFindOneAndUpdate, mockAggregate, mockSave } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindOne: vi.fn(), + mockFindOneAndUpdate: vi.fn(), + mockAggregate: vi.fn(), + mockSave: vi.fn(), +})); + +vi.mock('../../schemas/purchase.schema.js', () => { + const findChain = () => ({ + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: mockFind, + }); + const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne }); + const updateChain = () => ({ exec: mockFindOneAndUpdate }); + const aggregateChain = () => ({ exec: mockAggregate }); + + class FakeModel { + data: unknown; + constructor(data: unknown) { this.data = data; } + save = mockSave; + toObject() { return this.data; } + static find = vi.fn(() => findChain()); + static findOne = vi.fn(() => findOneChain()); + static findOneAndUpdate = vi.fn(() => updateChain()); + static aggregate = vi.fn(() => aggregateChain()); + } + return { PurchaseModel: FakeModel }; +}); + +import { PurchasesRepository } from './purchases.repository.js'; + +const makeItem = (overrides = {}) => ({ + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Tylenol', + quantity: 30, + unit: 'tablet', + addedToCabinet: false, + ...overrides, +}); + +describe(PurchasesRepository.name, () => { + let repo: PurchasesRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new PurchasesRepository(); + }); + + describe('create', () => { + it('saves and returns plain object', async () => { + const data = { householdId: 'hh1', storeId: 'st-1', storeName: 'CVS', status: 'in_cabinet', items: [makeItem()], purchasedAt: new Date(), createdBy: 'u-1' }; + mockSave.mockResolvedValue({ toObject: () => data }); + + const result = await repo.create(data); + + expect(mockSave).toHaveBeenCalled(); + expect(result).toEqual(data); + }); + }); + + describe('findByHousehold', () => { + it('returns paginated items without hasMore', async () => { + const items = [{ _id: { toString: () => 'p-1' }, householdId: 'hh1' }]; + mockFind.mockResolvedValue(items); + + const result = await repo.findByHousehold('hh1', { limit: 20 }); + + expect(result.data).toEqual(items); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.cursor).toBeNull(); + }); + + it('returns hasMore and cursor when results exceed limit', async () => { + const items = Array.from({ length: 21 }, (_, i) => ({ _id: { toString: () => `p-${i}` } })); + mockFind.mockResolvedValue(items); + + const result = await repo.findByHousehold('hh1', { limit: 20 }); + + expect(result.data).toHaveLength(20); + expect(result.pagination.hasMore).toBe(true); + expect(result.pagination.cursor).not.toBeNull(); + }); + + it('filters by status when provided', async () => { + mockFind.mockResolvedValue([]); + const { PurchaseModel } = await import('../../schemas/purchase.schema.js'); + + await repo.findByHousehold('hh1', { limit: 20, status: 'ordered' }); + + expect(PurchaseModel.find).toHaveBeenCalledWith( + expect.objectContaining({ status: 'ordered' }), + ); + }); + + it('filters by storeId when provided', async () => { + mockFind.mockResolvedValue([]); + const { PurchaseModel } = await import('../../schemas/purchase.schema.js'); + + await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' }); + + expect(PurchaseModel.find).toHaveBeenCalledWith( + expect.objectContaining({ storeId: 'st-1' }), + ); + }); + + it('applies cursor filter when provided', async () => { + mockFind.mockResolvedValue([]); + const cursor = Buffer.from('p-1').toString('base64'); + const { PurchaseModel } = await import('../../schemas/purchase.schema.js'); + + await repo.findByHousehold('hh1', { limit: 20, cursor }); + + expect(PurchaseModel.find).toHaveBeenCalledWith( + expect.objectContaining({ _id: { $lt: 'p-1' } }), + ); + }); + }); + + describe('findById', () => { + it('returns purchase when found', async () => { + const purchase = { _id: 'p-1', householdId: 'hh1' }; + mockFindOne.mockResolvedValue(purchase); + + const result = await repo.findById('p-1', 'hh1'); + + expect(result).toEqual(purchase); + }); + + it('returns null when not found', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await repo.findById('missing', 'hh1'); + + expect(result).toBeNull(); + }); + }); + + describe('update', () => { + it('updates notes and returns updated doc', async () => { + const updated = { _id: 'p-1', notes: 'new note' }; + mockFindOneAndUpdate.mockResolvedValue(updated); + + const result = await repo.update('p-1', 'hh1', { notes: 'new note' }); + + expect(result).toEqual(updated); + }); + + it('returns null when purchase not found', async () => { + mockFindOneAndUpdate.mockResolvedValue(null); + + const result = await repo.update('missing', 'hh1', {}); + + expect(result).toBeNull(); + }); + + it('includes items in update set when provided', async () => { + const updated = { _id: 'p-1' }; + mockFindOneAndUpdate.mockResolvedValue(updated); + const { PurchaseModel } = await import('../../schemas/purchase.schema.js'); + const items = [{ name: 'X', quantity: 1, unit: 'tablet', addedToCabinet: false }]; + + await repo.update('p-1', 'hh1', { items } as never); + + expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ $set: expect.objectContaining({ items }) }), + expect.anything(), + ); + }); + }); + + describe('receiveAll', () => { + it('sets status to in_cabinet and all items addedToCabinet', async () => { + const updated = { _id: 'p-1', status: 'in_cabinet' }; + mockFindOneAndUpdate.mockResolvedValue(updated); + const { PurchaseModel } = await import('../../schemas/purchase.schema.js'); + + const result = await repo.receiveAll('p-1', 'hh1'); + + expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'p-1', householdId: 'hh1', isDeleted: false }, + expect.objectContaining({ + $set: expect.objectContaining({ status: 'in_cabinet' }), + }), + expect.anything(), + ); + expect(result).toEqual(updated); + }); + }); + + describe('markItemsAddedToCabinet', () => { + it('builds per-index update set and calls findOneAndUpdate', async () => { + const updated = { _id: 'p-1', items: [] }; + mockFindOneAndUpdate.mockResolvedValue(updated); + const { PurchaseModel } = await import('../../schemas/purchase.schema.js'); + + const result = await repo.markItemsAddedToCabinet('p-1', 'hh1', [0, 2]); + + expect(PurchaseModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'p-1', householdId: 'hh1', isDeleted: false }, + expect.objectContaining({ + $set: expect.objectContaining({ + 'items.0.addedToCabinet': true, + 'items.2.addedToCabinet': true, + }), + }), + expect.anything(), + ); + expect(result).toEqual(updated); + }); + }); + + describe('softDelete', () => { + it('sets isDeleted to true', async () => { + mockFindOneAndUpdate.mockResolvedValue({ _id: 'p-1', isDeleted: true }); + + const result = await repo.softDelete('p-1', 'hh1'); + + expect(result).toBeTruthy(); + }); + }); + + describe('getPendingMedicineStock', () => { + it('returns aggregated stock by medicineId', async () => { + const rows = [{ medicineId: 'med-1', totalUnits: 60 }]; + mockAggregate.mockResolvedValue(rows); + + const result = await repo.getPendingMedicineStock('hh1'); + + expect(result).toEqual(rows); + }); + + it('returns empty array when no pending purchases', async () => { + mockAggregate.mockResolvedValue([]); + + const result = await repo.getPendingMedicineStock('hh1'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/api/src/modules/purchases/purchases.repository.ts b/packages/api/src/modules/purchases/purchases.repository.ts new file mode 100644 index 0000000..8adbf43 --- /dev/null +++ b/packages/api/src/modules/purchases/purchases.repository.ts @@ -0,0 +1,142 @@ +import { PurchaseModel } from '../../schemas/purchase.schema.js'; +import type { PurchaseQueryInput, UpdatePurchaseInput } from '@meshitrack/shared'; + +export interface CreatePurchaseItemData { + medicineProductId?: string; + medicineId?: string; + foodProductId?: string; + name: string; + quantity: number; + unit: string; + actualPrice?: number; + currency?: string; + priceRecordId?: string; + addedToCabinet?: boolean; +} + +export interface CreatePurchaseData { + householdId: string; + storeId: string; + storeName: string; + status: string; + items: CreatePurchaseItemData[]; + notes?: string; + purchasedAt: Date; + createdBy: string; +} + +export class PurchasesRepository { + public async create(data: CreatePurchaseData) { + const purchase = new PurchaseModel(data); + const saved = await purchase.save(); + return saved.toObject(); + } + + public async findByHousehold(householdId: string, query: PurchaseQueryInput) { + const filter: Record = { householdId, isDeleted: false }; + + if (query.status) filter['status'] = query.status; + if (query.storeId) filter['storeId'] = query.storeId; + + if (query.cursor) { + const id = Buffer.from(query.cursor, 'base64').toString(); + filter['_id'] = { $lt: id }; + } + + const limit = query.limit; + const items = await PurchaseModel.find(filter) + .sort({ _id: -1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const cursor = + data.length > 0 + ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') + : null; + + return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } }; + } + + public async findById(id: string, householdId: string) { + return PurchaseModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec(); + } + + public async update(id: string, householdId: string, data: UpdatePurchaseInput) { + const updateSet: Record = {}; + if (data.notes !== undefined) updateSet['notes'] = data.notes; + if (data.items !== undefined) updateSet['items'] = data.items; + + return PurchaseModel.findOneAndUpdate( + { _id: id, householdId, isDeleted: false }, + { $set: updateSet }, + { new: true, lean: true }, + ).exec(); + } + + public async markItemsAddedToCabinet( + purchaseId: string, + householdId: string, + itemIndices: number[], + ) { + // Build update using positional array filters + const updateSet: Record = {}; + for (const idx of itemIndices) { + updateSet[`items.${idx}.addedToCabinet`] = true; + } + + return PurchaseModel.findOneAndUpdate( + { _id: purchaseId, householdId, isDeleted: false }, + { $set: updateSet }, + { new: true, lean: true }, + ).exec(); + } + + public async receiveAll(purchaseId: string, householdId: string) { + return PurchaseModel.findOneAndUpdate( + { _id: purchaseId, householdId, isDeleted: false }, + { + $set: { + status: 'in_cabinet', + receivedAt: new Date(), + 'items.$[].addedToCabinet': true, + }, + }, + { new: true, lean: true }, + ).exec(); + } + + public async getPendingMedicineStock( + householdId: string, + ): Promise<{ medicineId: string; totalUnits: number }[]> { + const results = await PurchaseModel.aggregate([ + { $match: { householdId, status: 'ordered', isDeleted: false } }, + { $unwind: '$items' }, + { + $match: { + 'items.medicineId': { $exists: true, $ne: null }, + 'items.addedToCabinet': false, + }, + }, + { + $group: { + _id: '$items.medicineId', + totalUnits: { $sum: '$items.quantity' }, + }, + }, + { $project: { _id: 0, medicineId: '$_id', totalUnits: 1 } }, + ]).exec(); + + return results as { medicineId: string; totalUnits: number }[]; + } + + public async softDelete(id: string, householdId: string) { + return PurchaseModel.findOneAndUpdate( + { _id: id, householdId, isDeleted: false, status: 'ordered' }, + { $set: { isDeleted: true } }, + { new: true, lean: true }, + ).exec(); + } +} diff --git a/packages/api/src/modules/purchases/purchases.routes.test.ts b/packages/api/src/modules/purchases/purchases.routes.test.ts new file mode 100644 index 0000000..f757fb0 --- /dev/null +++ b/packages/api/src/modules/purchases/purchases.routes.test.ts @@ -0,0 +1,476 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + preferred_username: 'testuser', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: { alg: 'RS256' }, + key: {}, + }), +})); + +const { + mockList, + mockGetById, + mockCreate, + mockUpdate, + mockReceive, + mockDelete, +} = vi.hoisted(() => ({ + mockList: vi.fn(), + mockGetById: vi.fn(), + mockCreate: vi.fn(), + mockUpdate: vi.fn(), + mockReceive: vi.fn(), + mockDelete: vi.fn(), +})); + +vi.mock('./purchases.repository.js', () => ({ + PurchasesRepository: class { + create = vi.fn(); + findByHousehold = vi.fn(); + findById = vi.fn(); + update = vi.fn(); + receiveAll = vi.fn(); + softDelete = vi.fn(); + getPendingMedicineStock = vi.fn(); + }, +})); + +vi.mock('./purchases.service.js', () => ({ + PurchasesService: class { + list = mockList; + getById = mockGetById; + create = mockCreate; + update = mockUpdate; + receive = mockReceive; + delete = mockDelete; + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import purchasesRoutes from './purchases.routes.js'; + +function makeFakePurchase(overrides = {}) { + return { + _id: 'p-1', + householdId: 'hh1', + storeId: 'st-1', + storeName: 'CVS', + status: 'in_cabinet', + items: [ + { + _id: 'item-1', + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Advil', + quantity: 30, + unit: 'tablet', + addedToCabinet: true, + }, + ], + purchasedAt: '2026-01-15T00:00:00.000Z', + createdBy: 'kc-1', + createdAt: '2026-01-15T00:00:00.000Z', + updatedAt: '2026-01-15T00:00:00.000Z', + ...overrides, + }; +} + +describe('purchases.routes', () => { + let app: Awaited>; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(purchasesRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid-token' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + describe('GET /api/v1/households/:householdId/purchases', () => { + it('returns paginated purchase list', async () => { + mockList.mockResolvedValue({ + data: [makeFakePurchase()], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].storeName).toBe('CVS'); + expect(body.pagination.hasMore).toBe(false); + }); + + it('passes query params to service', async () => { + mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases?status=ordered&limit=10', + headers: authHeaders, + }); + + expect(mockList).toHaveBeenCalledWith( + 'hh1', + expect.objectContaining({ status: 'ordered', limit: 10 }), + ); + }); + + it('serializes ObjectId _id to string', async () => { + mockList.mockResolvedValue({ + data: [makeFakePurchase({ _id: { toString: () => 'p-obj' } })], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().data[0]._id).toBe('p-obj'); + }); + + it('converts Date objects to ISO strings', async () => { + mockList.mockResolvedValue({ + data: [ + makeFakePurchase({ + purchasedAt: new Date('2026-01-15T00:00:00.000Z'), + createdAt: new Date('2026-01-15T00:00:00.000Z'), + updatedAt: new Date('2026-01-15T00:00:00.000Z'), + }), + ], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const item = res.json().data[0]; + expect(item.purchasedAt).toBe('2026-01-15T00:00:00.000Z'); + expect(item.createdAt).toBe('2026-01-15T00:00:00.000Z'); + }); + + it('includes optional fields in item response when present', async () => { + mockList.mockResolvedValue({ + data: [ + makeFakePurchase({ + notes: 'picked up on the way home', + items: [ + { + _id: 'item-1', + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Advil', + quantity: 30, + unit: 'tablet', + actualPrice: 9.99, + currency: 'USD', + addedToCabinet: true, + }, + ], + }), + ], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const item = res.json().data[0].items[0]; + expect(item.actualPrice).toBe(9.99); + expect(item.currency).toBe('USD'); + expect(res.json().data[0].notes).toBe('picked up on the way home'); + }); + + it('handles item with ObjectId _id and priceRecordId', async () => { + mockList.mockResolvedValue({ + data: [ + makeFakePurchase({ + items: [ + { + _id: { toString: () => 'item-obj' }, + name: 'Advil', + quantity: 10, + unit: 'tablet', + priceRecordId: 'pr-1', + addedToCabinet: false, + }, + ], + }), + ], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const item = res.json().data[0].items[0]; + expect(item._id).toBe('item-obj'); + expect(item.priceRecordId).toBe('pr-1'); + }); + + it('handles item without _id and includes receivedAt on purchase', async () => { + mockList.mockResolvedValue({ + data: [ + makeFakePurchase({ + status: 'in_cabinet', + receivedAt: '2026-01-20T00:00:00.000Z', + items: [ + { + name: 'Generic', + quantity: 5, + unit: 'tablet', + addedToCabinet: true, + }, + ], + }), + ], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const purchase = res.json().data[0]; + expect(purchase.items[0]._id).toBe(''); + expect(purchase.receivedAt).toBe('2026-01-20T00:00:00.000Z'); + }); + }); + + describe('GET /api/v1/households/:householdId/purchases/:id', () => { + it('returns single purchase', async () => { + mockGetById.mockResolvedValue(makeFakePurchase()); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases/p-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().storeName).toBe('CVS'); + }); + + it('passes id and householdId to service', async () => { + mockGetById.mockResolvedValue(makeFakePurchase()); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/purchases/p-99', + headers: authHeaders, + }); + + expect(mockGetById).toHaveBeenCalledWith('p-99', 'hh1'); + }); + }); + + describe('POST /api/v1/households/:householdId/purchases', () => { + const validBody = { + storeId: 'st-1', + items: [{ name: 'Advil', quantity: 30, unit: 'tablet' }], + }; + + it('creates purchase and returns 201', async () => { + mockCreate.mockResolvedValue(makeFakePurchase()); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + payload: validBody, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().storeName).toBe('CVS'); + }); + + it('passes body, householdId, and userId to service', async () => { + mockCreate.mockResolvedValue(makeFakePurchase()); + + await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + payload: { ...validBody, status: 'ordered' }, + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ storeId: 'st-1', status: 'ordered' }), + 'hh1', + 'kc-1', + ); + }); + + it('returns 400 for missing storeId', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + payload: { items: [{ name: 'X', quantity: 1, unit: 'tablet' }] }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 400 for empty items array', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/purchases', + headers: authHeaders, + payload: { storeId: 'st-1', items: [] }, + }); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('PATCH /api/v1/households/:householdId/purchases/:id', () => { + it('updates purchase and returns 200', async () => { + mockUpdate.mockResolvedValue(makeFakePurchase({ notes: 'updated note' })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/purchases/p-1', + headers: authHeaders, + payload: { notes: 'updated note' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().notes).toBe('updated note'); + }); + + it('passes id, householdId, and body to service', async () => { + mockUpdate.mockResolvedValue(makeFakePurchase()); + + await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/purchases/p-1', + headers: authHeaders, + payload: { notes: 'note' }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + 'p-1', + 'hh1', + expect.objectContaining({ notes: 'note' }), + ); + }); + }); + + describe('POST /api/v1/households/:householdId/purchases/:id/receive', () => { + it('returns addedCount and priceRecordsCreated', async () => { + mockReceive.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 1 }); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/purchases/p-1/receive', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ addedCount: 2, priceRecordsCreated: 1 }); + }); + + it('passes id, householdId, and userId to service', async () => { + mockReceive.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 }); + + await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/purchases/p-1/receive', + headers: authHeaders, + }); + + expect(mockReceive).toHaveBeenCalledWith('p-1', 'hh1', 'kc-1'); + }); + }); + + describe('DELETE /api/v1/households/:householdId/purchases/:id', () => { + it('deletes purchase and returns 200', async () => { + mockDelete.mockResolvedValue(makeFakePurchase()); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/purchases/p-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()._id).toBe('p-1'); + }); + + it('passes id and householdId to service', async () => { + mockDelete.mockResolvedValue(makeFakePurchase()); + + await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/purchases/p-1', + headers: authHeaders, + }); + + expect(mockDelete).toHaveBeenCalledWith('p-1', 'hh1'); + }); + }); +}); diff --git a/packages/api/src/modules/purchases/purchases.routes.ts b/packages/api/src/modules/purchases/purchases.routes.ts new file mode 100644 index 0000000..7dbc2d4 --- /dev/null +++ b/packages/api/src/modules/purchases/purchases.routes.ts @@ -0,0 +1,214 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + CreatePurchaseSchema, + UpdatePurchaseSchema, + PurchaseQuerySchema, + PurchaseResponseSchema, + PurchaseListResponseSchema, +} from '@meshitrack/shared'; +import { PurchasesRepository } from './purchases.repository.js'; +import { PurchasesService } from './purchases.service.js'; + +function toIso(v: Date | string | { toISOString: () => string }): string { + if (typeof v === 'string') return v; + return v.toISOString(); +} + +type AnyPurchaseItem = { + _id?: string | { toString: () => string }; + medicineProductId?: string; + medicineId?: string; + foodProductId?: string; + name: string; + quantity: number; + unit: string; + actualPrice?: number; + currency?: string; + priceRecordId?: string; + addedToCabinet: boolean; +}; + +type AnyPurchase = { + _id: string | { toString: () => string }; + householdId: string; + storeId: string; + storeName: string; + status: string; + items: AnyPurchaseItem[]; + notes?: string; + purchasedAt: Date | string | { toISOString: () => string }; + receivedAt?: Date | string | { toISOString: () => string }; + createdBy: string; + createdAt: Date | string | { toISOString: () => string }; + updatedAt: Date | string | { toISOString: () => string }; +}; + +function toItemResponse(item: AnyPurchaseItem) { + return { + _id: item._id + ? typeof item._id === 'string' + ? item._id + : item._id.toString() + : '', + ...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}), + ...(item.medicineId ? { medicineId: item.medicineId } : {}), + ...(item.foodProductId ? { foodProductId: item.foodProductId } : {}), + name: item.name, + quantity: item.quantity, + unit: item.unit, + ...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}), + ...(item.currency ? { currency: item.currency } : {}), + ...(item.priceRecordId ? { priceRecordId: item.priceRecordId } : {}), + addedToCabinet: item.addedToCabinet, + }; +} + +function toPurchaseResponse(doc: AnyPurchase) { + return { + _id: typeof doc._id === 'string' ? doc._id : doc._id.toString(), + householdId: doc.householdId, + storeId: doc.storeId, + storeName: doc.storeName, + status: doc.status as 'ordered' | 'in_cabinet', + items: doc.items.map(toItemResponse), + ...(doc.notes ? { notes: doc.notes } : {}), + purchasedAt: toIso(doc.purchasedAt), + ...(doc.receivedAt ? { receivedAt: toIso(doc.receivedAt) } : {}), + createdBy: doc.createdBy, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + purchasesRepository: PurchasesRepository; + purchasesService: PurchasesService; + } +} + +export default fp( + async (fastify) => { + fastify.diContainer.register({ + purchasesRepository: asClass(PurchasesRepository, { lifetime: Lifetime.SINGLETON }), + purchasesService: asClass(PurchasesService, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/purchases', + schema: { + params: householdParams, + querystring: PurchaseQuerySchema, + response: { 200: PurchaseListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('purchasesService'); + const result = await service.list(request.params.householdId, request.query); + return reply.send({ + data: result.data.map(toPurchaseResponse), + pagination: result.pagination, + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/purchases/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: PurchaseResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('purchasesService'); + const purchase = await service.getById(request.params.id, request.params.householdId); + return reply.send(toPurchaseResponse(purchase)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/purchases', + schema: { + params: householdParams, + body: CreatePurchaseSchema, + response: { 201: PurchaseResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('purchasesService'); + const purchase = await service.create( + request.body, + request.params.householdId, + request.user.keycloakId, + ); + return reply.status(201).send(toPurchaseResponse(purchase)); + }, + }); + + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/purchases/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + body: UpdatePurchaseSchema, + response: { 200: PurchaseResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('purchasesService'); + const purchase = await service.update( + request.params.id, + request.params.householdId, + request.body, + ); + return reply.send(toPurchaseResponse(purchase)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/purchases/:id/receive', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { + 200: z.object({ + addedCount: z.number(), + priceRecordsCreated: z.number(), + }), + }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('purchasesService'); + const result = await service.receive( + request.params.id, + request.params.householdId, + request.user.keycloakId, + ); + return reply.send(result); + }, + }); + + app.route({ + method: 'DELETE', + url: '/api/v1/households/:householdId/purchases/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: PurchaseResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('purchasesService'); + const purchase = await service.delete(request.params.id, request.params.householdId); + return reply.send(toPurchaseResponse(purchase)); + }, + }); + }, + { + name: 'purchases-routes', + dependencies: ['auth-plugin'], + }, +); diff --git a/packages/api/src/modules/purchases/purchases.service.test.ts b/packages/api/src/modules/purchases/purchases.service.test.ts new file mode 100644 index 0000000..7aee85b --- /dev/null +++ b/packages/api/src/modules/purchases/purchases.service.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PurchasesService } from './purchases.service.js'; + +describe(PurchasesService.name, () => { + const mockPurchasesRepo = { + create: vi.fn(), + findByHousehold: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + receiveAll: vi.fn(), + softDelete: vi.fn(), + getPendingMedicineStock: vi.fn(), + }; + const mockCabinetService = { + addItem: vi.fn(), + }; + const mockStoresRepo = { + findById: vi.fn(), + }; + const mockProductsRepo = { + findById: vi.fn(), + }; + const mockPricesRepo = { + create: vi.fn(), + }; + + let service: PurchasesService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new PurchasesService({ + purchasesRepository: mockPurchasesRepo as never, + cabinetService: mockCabinetService as never, + storesRepository: mockStoresRepo as never, + medicineProductsRepository: mockProductsRepo as never, + medicinePricesRepository: mockPricesRepo as never, + }); + }); + + const fakeStore = { _id: 'st-1', name: 'CVS' }; + const fakeProduct = { _id: 'mp-1', medicineId: 'med-1', medicineName: 'Ibuprofen', brand: 'Advil' }; + + describe('create', () => { + const validInput = { + storeId: 'st-1', + status: 'in_cabinet' as const, + items: [{ name: 'Advil', quantity: 30, unit: 'tablet', medicineProductId: 'mp-1' }], + }; + + it('throws NotFoundError when store not found', async () => { + mockStoresRepo.findById.mockResolvedValue(null); + + await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow('Store not found'); + }); + + it('throws NotFoundError when medicine product not found', async () => { + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockProductsRepo.findById.mockResolvedValue(null); + + await expect(service.create(validInput, 'hh1', 'user-1')).rejects.toThrow( + 'Medicine product not found: mp-1', + ); + }); + + it('creates purchase with in_cabinet status and adds items to cabinet', async () => { + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockProductsRepo.findById.mockResolvedValue(fakeProduct); + mockCabinetService.addItem.mockResolvedValue({}); + const purchase = { _id: 'p-1', status: 'in_cabinet' }; + mockPurchasesRepo.create.mockResolvedValue(purchase); + + const result = await service.create(validInput, 'hh1', 'user-1'); + + expect(mockCabinetService.addItem).toHaveBeenCalledOnce(); + expect(mockPurchasesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ status: 'in_cabinet', storeName: 'CVS' }), + ); + expect(result).toEqual(purchase); + }); + + it('records price when actualPrice is set and status is in_cabinet', async () => { + const inputWithPrice = { + ...validInput, + items: [{ ...validInput.items[0], actualPrice: 9.99, currency: 'USD' }], + }; + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockProductsRepo.findById.mockResolvedValue(fakeProduct); + mockCabinetService.addItem.mockResolvedValue({}); + mockPricesRepo.create.mockResolvedValue({}); + mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' }); + + await service.create(inputWithPrice, 'hh1', 'user-1'); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + price: 9.99, + medicineName: 'Ibuprofen', + storeName: 'CVS', + pricePerUnit: expect.closeTo(0.333, 2), + }), + ); + }); + + it('does not add to cabinet when status is ordered', async () => { + const orderedInput = { ...validInput, status: 'ordered' as const }; + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockProductsRepo.findById.mockResolvedValue(fakeProduct); + mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1', status: 'ordered' }); + + await service.create(orderedInput, 'hh1', 'user-1'); + + expect(mockCabinetService.addItem).not.toHaveBeenCalled(); + expect(mockPricesRepo.create).not.toHaveBeenCalled(); + }); + + it('handles item without medicineProductId for in_cabinet', async () => { + const noProductInput = { + storeId: 'st-1', + status: 'in_cabinet' as const, + items: [{ name: 'Generic OTC', quantity: 1, unit: 'tablet' }], + }; + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' }); + + await service.create(noProductInput, 'hh1', 'user-1'); + + expect(mockCabinetService.addItem).not.toHaveBeenCalled(); + expect(mockPurchasesRepo.create).toHaveBeenCalled(); + }); + + it('uses purchasedAt from input when provided', async () => { + const inputWithDate = { ...validInput, purchasedAt: '2026-01-15T00:00:00.000Z' }; + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockProductsRepo.findById.mockResolvedValue(fakeProduct); + mockCabinetService.addItem.mockResolvedValue({}); + mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' }); + + await service.create(inputWithDate, 'hh1', 'user-1'); + + expect(mockPurchasesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ purchasedAt: new Date('2026-01-15T00:00:00.000Z') }), + ); + }); + + it('uses medicineName as brand fallback when brand is undefined', async () => { + mockStoresRepo.findById.mockResolvedValue(fakeStore); + mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined }); + mockCabinetService.addItem.mockResolvedValue({}); + mockPricesRepo.create.mockResolvedValue({}); + const inputWithPrice = { + ...validInput, + items: [{ ...validInput.items[0], actualPrice: 5 }], + }; + mockPurchasesRepo.create.mockResolvedValue({ _id: 'p-1' }); + + await service.create(inputWithPrice, 'hh1', 'user-1'); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }), + ); + }); + }); + + describe('receive', () => { + it('throws NotFoundError when purchase not found', async () => { + mockPurchasesRepo.findById.mockResolvedValue(null); + + await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow('Purchase not found'); + }); + + it('throws BadRequestError when status is not ordered', async () => { + mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet', items: [] }); + + await expect(service.receive('p-1', 'hh1', 'user-1')).rejects.toThrow( + 'Purchase is not in ordered status', + ); + }); + + it('adds medicine items to cabinet and calls receiveAll', async () => { + const purchase = { + _id: 'p-1', + status: 'ordered', + storeId: 'st-1', + storeName: 'CVS', + purchasedAt: new Date('2026-01-01T00:00:00.000Z'), + items: [ + { + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Advil', + quantity: 30, + unit: 'tablet', + addedToCabinet: false, + }, + ], + }; + mockPurchasesRepo.findById.mockResolvedValue(purchase); + mockCabinetService.addItem.mockResolvedValue({}); + mockPurchasesRepo.receiveAll.mockResolvedValue({ _id: 'p-1', status: 'in_cabinet' }); + + const result = await service.receive('p-1', 'hh1', 'user-1'); + + expect(mockCabinetService.addItem).toHaveBeenCalledOnce(); + expect(mockPurchasesRepo.receiveAll).toHaveBeenCalledWith('p-1', 'hh1'); + expect(result.addedCount).toBe(1); + expect(result.priceRecordsCreated).toBe(0); + }); + + it('creates price record when actualPrice is set on item', async () => { + const purchase = { + _id: 'p-1', + status: 'ordered', + storeId: 'st-1', + storeName: 'CVS', + purchasedAt: new Date('2026-01-01T00:00:00.000Z'), + items: [ + { + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Advil', + quantity: 30, + unit: 'tablet', + actualPrice: 9.99, + currency: 'USD', + addedToCabinet: false, + }, + ], + }; + mockPurchasesRepo.findById.mockResolvedValue(purchase); + mockCabinetService.addItem.mockResolvedValue({}); + mockProductsRepo.findById.mockResolvedValue(fakeProduct); + mockPricesRepo.create.mockResolvedValue({}); + mockPurchasesRepo.receiveAll.mockResolvedValue({}); + + const result = await service.receive('p-1', 'hh1', 'user-1'); + + expect(mockPricesRepo.create).toHaveBeenCalledOnce(); + expect(result.priceRecordsCreated).toBe(1); + }); + + it('uses medicineName as brand fallback in price record when brand is undefined', async () => { + const purchase = { + _id: 'p-1', + status: 'ordered', + storeId: 'st-1', + storeName: 'CVS', + purchasedAt: new Date('2026-01-01T00:00:00.000Z'), + items: [ + { + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Ibuprofen', + quantity: 30, + unit: 'tablet', + actualPrice: 9.99, + currency: 'USD', + addedToCabinet: false, + }, + ], + }; + mockPurchasesRepo.findById.mockResolvedValue(purchase); + mockCabinetService.addItem.mockResolvedValue({}); + mockProductsRepo.findById.mockResolvedValue({ ...fakeProduct, brand: undefined }); + mockPricesRepo.create.mockResolvedValue({}); + mockPurchasesRepo.receiveAll.mockResolvedValue({}); + + await service.receive('p-1', 'hh1', 'user-1'); + + expect(mockPricesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ medicineProductBrand: 'Ibuprofen' }), + ); + }); + + it('skips price record creation when product not found in receive', async () => { + const purchase = { + _id: 'p-1', + status: 'ordered', + storeId: 'st-1', + storeName: 'CVS', + purchasedAt: new Date(), + items: [ + { + medicineProductId: 'mp-1', + medicineId: 'med-1', + name: 'Advil', + quantity: 30, + unit: 'tablet', + actualPrice: 9.99, + addedToCabinet: false, + }, + ], + }; + mockPurchasesRepo.findById.mockResolvedValue(purchase); + mockCabinetService.addItem.mockResolvedValue({}); + mockProductsRepo.findById.mockResolvedValue(null); + mockPurchasesRepo.receiveAll.mockResolvedValue({}); + + const result = await service.receive('p-1', 'hh1', 'user-1'); + + expect(mockPricesRepo.create).not.toHaveBeenCalled(); + expect(result.priceRecordsCreated).toBe(0); + }); + + it('skips items already added to cabinet', async () => { + const purchase = { + _id: 'p-1', + status: 'ordered', + storeId: 'st-1', + storeName: 'CVS', + purchasedAt: new Date(), + items: [ + { medicineProductId: 'mp-1', medicineId: 'med-1', name: 'X', quantity: 10, unit: 'tablet', addedToCabinet: true }, + ], + }; + mockPurchasesRepo.findById.mockResolvedValue(purchase); + mockPurchasesRepo.receiveAll.mockResolvedValue({}); + + const result = await service.receive('p-1', 'hh1', 'user-1'); + + expect(mockCabinetService.addItem).not.toHaveBeenCalled(); + expect(result.addedCount).toBe(0); + }); + }); + + describe('list', () => { + it('delegates to repository', async () => { + const result = { data: [], pagination: { cursor: null, hasMore: false } }; + mockPurchasesRepo.findByHousehold.mockResolvedValue(result); + + const response = await service.list('hh1', { limit: 20 }); + + expect(response).toEqual(result); + expect(mockPurchasesRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 }); + }); + }); + + describe('getById', () => { + it('returns purchase when found', async () => { + const purchase = { _id: 'p-1', status: 'in_cabinet' }; + mockPurchasesRepo.findById.mockResolvedValue(purchase); + + expect(await service.getById('p-1', 'hh1')).toEqual(purchase); + }); + + it('throws NotFoundError when not found', async () => { + mockPurchasesRepo.findById.mockResolvedValue(null); + + await expect(service.getById('missing', 'hh1')).rejects.toThrow('Purchase not found'); + }); + }); + + describe('update', () => { + it('updates and returns purchase', async () => { + mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' }); + const updated = { _id: 'p-1', notes: 'updated' }; + mockPurchasesRepo.update.mockResolvedValue(updated); + + const result = await service.update('p-1', 'hh1', { notes: 'updated' }); + + expect(result).toEqual(updated); + }); + + it('throws NotFoundError when purchase does not exist', async () => { + mockPurchasesRepo.findById.mockResolvedValue(null); + + await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Purchase not found'); + }); + + it('throws NotFoundError when update returns null', async () => { + mockPurchasesRepo.findById.mockResolvedValue({ _id: 'p-1' }); + mockPurchasesRepo.update.mockResolvedValue(null); + + await expect(service.update('p-1', 'hh1', {})).rejects.toThrow('Purchase not found'); + }); + }); + + describe('delete', () => { + it('soft-deletes and returns purchase', async () => { + const deleted = { _id: 'p-1', isDeleted: true }; + mockPurchasesRepo.softDelete.mockResolvedValue(deleted); + + const result = await service.delete('p-1', 'hh1'); + + expect(result).toEqual(deleted); + expect(mockPurchasesRepo.softDelete).toHaveBeenCalledWith('p-1', 'hh1'); + }); + + it('throws NotFoundError when purchase not found', async () => { + mockPurchasesRepo.softDelete.mockResolvedValue(null); + + await expect(service.delete('missing', 'hh1')).rejects.toThrow( + 'Purchase not found or cannot be deleted', + ); + }); + }); + + describe('getPendingStockByMedicine', () => { + it('returns map of medicineId to totalUnits', async () => { + mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([ + { medicineId: 'med-1', totalUnits: 60 }, + { medicineId: 'med-2', totalUnits: 30 }, + ]); + + const result = await service.getPendingStockByMedicine('hh1'); + + expect(result.get('med-1')).toBe(60); + expect(result.get('med-2')).toBe(30); + }); + + it('returns empty map when no pending stock', async () => { + mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]); + + const result = await service.getPendingStockByMedicine('hh1'); + + expect(result.size).toBe(0); + }); + }); +}); diff --git a/packages/api/src/modules/purchases/purchases.service.ts b/packages/api/src/modules/purchases/purchases.service.ts new file mode 100644 index 0000000..06add7c --- /dev/null +++ b/packages/api/src/modules/purchases/purchases.service.ts @@ -0,0 +1,272 @@ +import type { PurchasesRepository } from './purchases.repository.js'; +import type { StoresRepository } from '../stores/stores.repository.js'; +import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js'; +import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js'; +import type { CabinetService } from '../cabinet/cabinet.service.js'; +import type { + CreatePurchaseInput, + UpdatePurchaseInput, + PurchaseQueryInput, +} from '@meshitrack/shared'; +import { DosageUnit } from '@meshitrack/shared'; +import { NotFoundError, BadRequestError } from '../../common/errors.js'; + +interface Deps { + purchasesRepository: PurchasesRepository; + cabinetService: CabinetService; + storesRepository: StoresRepository; + medicineProductsRepository: MedicineProductsRepository; + medicinePricesRepository: MedicinePricesRepository; +} + +export class PurchasesService { + private readonly purchasesRepository: PurchasesRepository; + private readonly cabinetService: CabinetService; + private readonly storesRepository: StoresRepository; + private readonly medicineProductsRepository: MedicineProductsRepository; + private readonly medicinePricesRepository: MedicinePricesRepository; + + public constructor({ + purchasesRepository, + cabinetService, + storesRepository, + medicineProductsRepository, + medicinePricesRepository, + }: Deps) { + this.purchasesRepository = purchasesRepository; + this.cabinetService = cabinetService; + this.storesRepository = storesRepository; + this.medicineProductsRepository = medicineProductsRepository; + this.medicinePricesRepository = medicinePricesRepository; + } + + public async create(data: CreatePurchaseInput, householdId: string, userId: string) { + const store = await this.storesRepository.findById(data.storeId, householdId); + if (!store) throw new NotFoundError('Store not found'); + + const purchasedAt = data.purchasedAt ? new Date(data.purchasedAt) : new Date(); + + const items: Array<{ + medicineProductId?: string; + medicineId?: string; + name: string; + quantity: number; + unit: string; + actualPrice?: number; + currency?: string; + priceRecordId?: string; + addedToCabinet: boolean; + }> = []; + + for (const item of data.items) { + let resolvedName = item.name; + let resolvedMedicineId: string | undefined; + + if (item.medicineProductId) { + const product = await this.medicineProductsRepository.findById( + item.medicineProductId, + householdId, + ); + if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`); + if (!resolvedName || resolvedName === item.name) { + resolvedName = product.brand ?? resolvedName; + } + resolvedMedicineId = product.medicineId as string; + } + + items.push({ + medicineProductId: item.medicineProductId, + medicineId: resolvedMedicineId, + name: resolvedName, + quantity: item.quantity, + unit: item.unit, + actualPrice: item.actualPrice, + currency: item.currency, + addedToCabinet: false, + }); + } + + if (data.status === 'in_cabinet') { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.medicineProductId && item.medicineId) { + await this.cabinetService.addItem( + { + medicineId: item.medicineId, + medicineProductId: item.medicineProductId, + quantity: item.quantity, + unit: item.unit as DosageUnit, + unitPrice: + item.actualPrice !== undefined && item.quantity > 0 + ? item.actualPrice / item.quantity + : undefined, + totalPrice: item.actualPrice, + storeId: data.storeId, + purchaseDate: purchasedAt.toISOString(), + }, + householdId, + userId, + ); + + if (item.actualPrice !== undefined) { + const product = await this.medicineProductsRepository.findById( + item.medicineProductId, + householdId, + ); + if (product) { + await this.medicinePricesRepository.create({ + householdId, + medicineProductId: item.medicineProductId, + medicineProductBrand: (product.brand as string) ?? (product.medicineName as string), + medicineId: item.medicineId, + /* v8 ignore next */ + medicineName: (product.medicineName as string) ?? '', + storeId: data.storeId, + storeName: store.name as string, + price: item.actualPrice, + currency: item.currency ?? 'USD', + quantity: item.quantity, + unit: item.unit, + /* v8 ignore next */ + pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice, + date: purchasedAt, + isInsurancePrice: false, + createdBy: userId, + }); + } + } + + items[i] = { ...item, addedToCabinet: true }; + } + } + } + + return this.purchasesRepository.create({ + householdId, + storeId: data.storeId, + storeName: store.name as string, + status: data.status, + items, + notes: data.notes, + purchasedAt, + createdBy: userId, + }); + } + + public async receive(id: string, householdId: string, userId: string) { + const purchase = await this.purchasesRepository.findById(id, householdId); + if (!purchase) throw new NotFoundError('Purchase not found'); + if (purchase.status !== 'ordered') { + throw new BadRequestError('Purchase is not in ordered status'); + } + + let addedCount = 0; + let priceRecordsCreated = 0; + + const itemsToUpdate: number[] = []; + + const items = purchase.items as Array<{ + medicineProductId?: string; + medicineId?: string; + name: string; + quantity: number; + unit: string; + actualPrice?: number; + currency?: string; + addedToCabinet: boolean; + }>; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.medicineProductId && item.medicineId && !item.addedToCabinet) { + await this.cabinetService.addItem( + { + medicineId: item.medicineId, + medicineProductId: item.medicineProductId, + quantity: item.quantity, + unit: item.unit as DosageUnit, + unitPrice: + item.actualPrice !== undefined && item.quantity > 0 + ? item.actualPrice / item.quantity + : undefined, + totalPrice: item.actualPrice, + storeId: purchase.storeId as string, + purchaseDate: (purchase.purchasedAt as Date).toISOString(), + }, + householdId, + userId, + ); + addedCount++; + + if (item.actualPrice !== undefined) { + const product = await this.medicineProductsRepository.findById( + item.medicineProductId, + householdId, + ); + if (product) { + await this.medicinePricesRepository.create({ + householdId, + medicineProductId: item.medicineProductId, + medicineProductBrand: (product.brand as string) ?? (product.medicineName as string), + medicineId: item.medicineId, + /* v8 ignore next */ + medicineName: (product.medicineName as string) ?? '', + storeId: purchase.storeId as string, + storeName: purchase.storeName as string, + price: item.actualPrice, + currency: item.currency ?? 'USD', + quantity: item.quantity, + unit: item.unit, + /* v8 ignore next */ + pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice, + date: purchase.purchasedAt as Date, + isInsurancePrice: false, + createdBy: userId, + }); + priceRecordsCreated++; + } + } + + itemsToUpdate.push(i); + } + } + + await this.purchasesRepository.receiveAll(id, householdId); + + return { addedCount, priceRecordsCreated }; + } + + public async list(householdId: string, query: PurchaseQueryInput) { + return this.purchasesRepository.findByHousehold(householdId, query); + } + + public async getById(id: string, householdId: string) { + const purchase = await this.purchasesRepository.findById(id, householdId); + if (!purchase) throw new NotFoundError('Purchase not found'); + return purchase; + } + + public async update(id: string, householdId: string, data: UpdatePurchaseInput) { + await this.getById(id, householdId); + const updated = await this.purchasesRepository.update(id, householdId, data); + if (!updated) throw new NotFoundError('Purchase not found'); + return updated; + } + + public async delete(id: string, householdId: string) { + const deleted = await this.purchasesRepository.softDelete(id, householdId); + if (!deleted) throw new NotFoundError('Purchase not found or cannot be deleted'); + return deleted; + } + + public async getPendingStockByMedicine( + householdId: string, + ): Promise> { + const results = await this.purchasesRepository.getPendingMedicineStock(householdId); + const map = new Map(); + for (const r of results) { + map.set(r.medicineId, r.totalUnits); + } + return map; + } +} diff --git a/packages/api/src/modules/refills/refills.repository.test.ts b/packages/api/src/modules/refills/refills.repository.test.ts new file mode 100644 index 0000000..e7333d7 --- /dev/null +++ b/packages/api/src/modules/refills/refills.repository.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindOne: vi.fn(), + mockFindOneAndUpdate: vi.fn(), + mockSave: vi.fn(), +})); + +vi.mock('../../schemas/refill-list.schema.js', () => { + const chain = () => ({ + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: mockFind, + }); + const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne }); + const updateChain = () => ({ exec: mockFindOneAndUpdate }); + + class FakeModel { + data: unknown; + constructor(data: unknown) { this.data = data; } + save = mockSave; + toObject() { return this.data; } + static find = vi.fn(() => chain()); + static findOne = vi.fn(() => findOneChain()); + static findOneAndUpdate = vi.fn(() => updateChain()); + } + return { RefillListModel: FakeModel }; +}); + +import { RefillsRepository } from './refills.repository.js'; + +describe(RefillsRepository.name, () => { + let repo: RefillsRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new RefillsRepository(); + }); + + describe('create', () => { + it('saves and returns refill list', async () => { + const data = { + householdId: 'hh1', + name: 'Monthly Refills', + status: 'active', + createdBy: 'user-1', + items: [], + }; + mockSave.mockImplementation(function (this: { toObject: () => unknown }) { + return Promise.resolve(this); + }); + + const result = await repo.create(data); + + expect(mockSave).toHaveBeenCalled(); + expect(result).toBeTruthy(); + }); + }); + + describe('findByHousehold', () => { + it('returns paginated lists', async () => { + const lists = [{ _id: 'rl-1', name: 'Monthly Refills' }]; + mockFind.mockResolvedValue(lists); + + const result = await repo.findByHousehold('hh1', { limit: 20 }); + + expect(result.data).toEqual(lists); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.cursor).toBeNull(); + }); + + it('sets hasMore when more lists exist', async () => { + const lists = [{ _id: 'rl-1' }, { _id: 'rl-2' }, { _id: 'rl-3' }]; + mockFind.mockResolvedValue(lists); + + const result = await repo.findByHousehold('hh1', { limit: 2 }); + + expect(result.data).toHaveLength(2); + expect(result.pagination.hasMore).toBe(true); + expect(result.pagination.cursor).toBeTruthy(); + }); + + it('handles cursor pagination', async () => { + mockFind.mockResolvedValue([]); + + const cursor = Buffer.from('rl-1').toString('base64'); + const result = await repo.findByHousehold('hh1', { cursor, limit: 20 }); + + expect(result.pagination.hasMore).toBe(false); + }); + + it('filters by status', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByHousehold('hh1', { status: 'active' as never, limit: 20 }); + + expect(mockFind).toHaveBeenCalled(); + }); + + it('returns null cursor when no data', async () => { + mockFind.mockResolvedValue([]); + + const result = await repo.findByHousehold('hh1', { limit: 20 }); + + expect(result.pagination.cursor).toBeNull(); + }); + }); + + describe('findById', () => { + it('returns list when found', async () => { + const list = { _id: 'rl-1', name: 'Monthly Refills' }; + mockFindOne.mockResolvedValue(list); + + const result = await repo.findById('rl-1', 'hh1'); + + expect(result).toEqual(list); + }); + + it('returns null when not found', async () => { + mockFindOne.mockResolvedValue(null); + + expect(await repo.findById('missing', 'hh1')).toBeNull(); + }); + }); + + describe('update', () => { + it('updates and returns list', async () => { + const updated = { _id: 'rl-1', name: 'Updated' }; + mockFindOneAndUpdate.mockResolvedValue(updated); + + const result = await repo.update('rl-1', 'hh1', { name: 'Updated' }); + + expect(result).toEqual(updated); + }); + + it('updates status field', async () => { + mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', status: 'shopping' }); + + const result = await repo.update('rl-1', 'hh1', { status: 'shopping' as never }); + + expect(result).toBeTruthy(); + }); + + it('updates preferredStoreId field', async () => { + mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', preferredStoreId: 'st-1' }); + + const result = await repo.update('rl-1', 'hh1', { preferredStoreId: 'st-1' }); + + expect(result).toBeTruthy(); + }); + + it('returns null when not found', async () => { + mockFindOneAndUpdate.mockResolvedValue(null); + + expect(await repo.update('missing', 'hh1', {})).toBeNull(); + }); + }); + + describe('updateItem', () => { + it('updates item and returns list', async () => { + const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] }; + mockFindOneAndUpdate.mockResolvedValue(updated); + + const result = await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true }); + + expect(result).toEqual(updated); + }); + + it('updates actualPrice, storeId, and notes', async () => { + mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] }); + + await repo.updateItem('rl-1', 'hh1', 'item-1', { + actualPrice: 9.99, + storeId: 'st-1', + notes: 'picked up at CVS', + }); + + expect(mockFindOneAndUpdate).toHaveBeenCalled(); + }); + + it('includes checkedAt when provided', async () => { + mockFindOneAndUpdate.mockResolvedValue({ _id: 'rl-1', items: [] }); + + const checkedAt = new Date(); + await repo.updateItem('rl-1', 'hh1', 'item-1', { checked: true, checkedAt }); + + expect(mockFindOneAndUpdate).toHaveBeenCalled(); + }); + }); + + describe('markItemsAddedToCabinet', () => { + it('marks items and returns list', async () => { + const updated = { _id: 'rl-1', items: [] }; + mockFindOneAndUpdate.mockResolvedValue(updated); + + const result = await repo.markItemsAddedToCabinet('rl-1', 'hh1', ['item-1', 'item-2']); + + expect(result).toEqual(updated); + }); + }); +}); diff --git a/packages/api/src/modules/refills/refills.repository.ts b/packages/api/src/modules/refills/refills.repository.ts new file mode 100644 index 0000000..c8a31a7 --- /dev/null +++ b/packages/api/src/modules/refills/refills.repository.ts @@ -0,0 +1,104 @@ +import { RefillListModel } from '../../schemas/refill-list.schema.js'; +import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared'; + +export interface CreateRefillListData { + householdId: string; + name: string; + status: string; + preferredStoreId?: string; + totalEstimatedCost?: number; + createdBy: string; + items: Array<{ + medicineId: string; + medicineName: string; + quantity: number; + unit: string; + estimatedPrice?: number; + storeId?: string; + notes?: string; + }>; +} + +export class RefillsRepository { + public async create(data: CreateRefillListData) { + const list = new RefillListModel(data); + const saved = await list.save(); + return saved.toObject(); + } + + public async findByHousehold(householdId: string, query: RefillListQueryInput) { + const filter: Record = { householdId }; + + if (query.status) filter['status'] = query.status; + + if (query.cursor) { + const id = Buffer.from(query.cursor, 'base64').toString(); + filter['_id'] = { $lt: id }; + } + + const limit = query.limit; + const items = await RefillListModel.find(filter) + .sort({ _id: -1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const cursor = + data.length > 0 + ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') + : null; + + return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } }; + } + + public async findById(id: string, householdId: string) { + return RefillListModel.findOne({ _id: id, householdId }).lean().exec(); + } + + public async update(id: string, householdId: string, data: UpdateRefillListInput) { + const updateSet: Record = {}; + if (data.name !== undefined) updateSet['name'] = data.name; + if (data.status !== undefined) updateSet['status'] = data.status; + if (data.preferredStoreId !== undefined) updateSet['preferredStoreId'] = data.preferredStoreId; + + return RefillListModel.findOneAndUpdate( + { _id: id, householdId }, + { $set: updateSet }, + { new: true, lean: true }, + ).exec(); + } + + public async updateItem( + listId: string, + householdId: string, + itemId: string, + data: UpdateRefillListItemInput & { checkedAt?: Date }, + ) { + const updateSet: Record = {}; + if (data.checked !== undefined) updateSet['items.$.checked'] = data.checked; + if (data.actualPrice !== undefined) updateSet['items.$.actualPrice'] = data.actualPrice; + if (data.storeId !== undefined) updateSet['items.$.storeId'] = data.storeId; + if (data.notes !== undefined) updateSet['items.$.notes'] = data.notes; + if (data.checkedAt !== undefined) updateSet['items.$.checkedAt'] = data.checkedAt; + + return RefillListModel.findOneAndUpdate( + { _id: listId, householdId, 'items._id': itemId }, + { $set: updateSet }, + { new: true, lean: true }, + ).exec(); + } + + public async markItemsAddedToCabinet(listId: string, householdId: string, itemIds: string[]) { + return RefillListModel.findOneAndUpdate( + { _id: listId, householdId }, + { $set: { 'items.$[elem].addedToCabinet': true } }, + { + arrayFilters: [{ 'elem._id': { $in: itemIds } }], + new: true, + lean: true, + }, + ).exec(); + } +} diff --git a/packages/api/src/modules/refills/refills.routes.test.ts b/packages/api/src/modules/refills/refills.routes.test.ts new file mode 100644 index 0000000..d414860 --- /dev/null +++ b/packages/api/src/modules/refills/refills.routes.test.ts @@ -0,0 +1,506 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + preferred_username: 'testuser', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: { alg: 'RS256' }, + key: {}, + }), +})); + +const { + mockGetAlerts, + mockCreateList, + mockList, + mockGetById, + mockUpdateList, + mockUpdateItem, + mockAddToCabinet, + mockGetStoreComparison, +} = vi.hoisted(() => ({ + mockGetAlerts: vi.fn(), + mockCreateList: vi.fn(), + mockList: vi.fn(), + mockGetById: vi.fn(), + mockUpdateList: vi.fn(), + mockUpdateItem: vi.fn(), + mockAddToCabinet: vi.fn(), + mockGetStoreComparison: vi.fn(), +})); + +vi.mock('./refills.repository.js', () => ({ + RefillsRepository: class { + create = vi.fn(); + findByHousehold = vi.fn(); + findById = vi.fn(); + update = vi.fn(); + updateItem = vi.fn(); + markItemsAddedToCabinet = vi.fn(); + }, +})); + +vi.mock('./refills.service.js', () => ({ + RefillsService: class { + getAlerts = mockGetAlerts; + createList = mockCreateList; + list = mockList; + getById = mockGetById; + updateList = mockUpdateList; + updateItem = mockUpdateItem; + addToCabinet = mockAddToCabinet; + getStoreComparison = mockGetStoreComparison; + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import refillsRoutes from './refills.routes.js'; + +function makeFakeRefillList(overrides = {}) { + return { + _id: 'rl-1', + householdId: 'hh1', + name: 'Monthly Refills', + items: [], + status: 'active', + createdBy: 'kc-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('refills.routes', () => { + let app: Awaited>; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(refillsRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid-token' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + describe('GET /api/v1/households/:householdId/refills/alerts', () => { + it('returns alerts with price options', async () => { + mockGetAlerts.mockResolvedValue([ + { + medicineId: 'med-1', + medicineName: 'Aspirin', + medicineStrength: 500, + medicineStrengthUnit: 'mg', + daysUntilEmpty: 3, + dailyConsumption: 2, + currentStock: 6, + suggestedQuantity: 60, + lastKnownPrice: { price: 10, pricePerUnit: 0.1, storeName: 'CVS', storeId: 'st-1', date: new Date('2026-01-01T00:00:00.000Z') }, + cheapestOption: { price: 8, pricePerUnit: 0.08, storeName: 'Walmart', storeId: 'st-2', date: new Date('2026-01-02T00:00:00.000Z') }, + }, + ]); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/alerts', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data[0].lastKnownPrice.storeName).toBe('CVS'); + expect(body.data[0].lastKnownPrice.date).toBe('2026-01-01T00:00:00.000Z'); + expect(body.data[0].cheapestOption.storeName).toBe('Walmart'); + }); + + it('returns alerts', async () => { + mockGetAlerts.mockResolvedValue([ + { + medicineId: 'med-1', + medicineName: 'Aspirin', + medicineStrength: 500, + medicineStrengthUnit: 'mg', + daysUntilEmpty: 3, + dailyConsumption: 2, + currentStock: 6, + suggestedQuantity: 60, + }, + ]); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/alerts', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].medicineName).toBe('Aspirin'); + }); + + it('uses requesting user by default', async () => { + mockGetAlerts.mockResolvedValue([]); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/alerts', + headers: authHeaders, + }); + + expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'kc-1', 7); + }); + + it('uses userId query param when provided', async () => { + mockGetAlerts.mockResolvedValue([]); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/alerts?userId=other-user&thresholdDays=14', + headers: authHeaders, + }); + + expect(mockGetAlerts).toHaveBeenCalledWith('hh1', 'other-user', 14); + }); + }); + + describe('POST /api/v1/households/:householdId/refills/lists', () => { + it('creates list and returns 201', async () => { + mockCreateList.mockResolvedValue(makeFakeRefillList()); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/refills/lists', + headers: authHeaders, + payload: { name: 'Monthly Refills', fromAlerts: false, thresholdDays: 7 }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().name).toBe('Monthly Refills'); + }); + + it('passes householdId and userId to service', async () => { + mockCreateList.mockResolvedValue(makeFakeRefillList()); + + await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/refills/lists', + headers: authHeaders, + payload: { name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, + }); + + expect(mockCreateList).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Auto List', fromAlerts: true }), + 'hh1', + 'kc-1', + ); + }); + + it('returns 400 for missing name', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/refills/lists', + headers: authHeaders, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('GET /api/v1/households/:householdId/refills/lists', () => { + it('returns paginated lists', async () => { + mockList.mockResolvedValue({ + data: [makeFakeRefillList()], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.pagination.hasMore).toBe(false); + }); + + it('includes optional list fields in response', async () => { + mockList.mockResolvedValue({ + data: [makeFakeRefillList({ + preferredStoreId: 'st-1', + totalEstimatedCost: 25.5, + items: [ + { + _id: 'item-1', + medicineId: 'med-1', + medicineName: 'Aspirin', + quantity: 30, + unit: 'tablet', + estimatedPrice: 10, + actualPrice: 9.5, + checked: true, + checkedAt: new Date('2026-01-10T00:00:00.000Z'), + addedToCabinet: false, + storeId: 'st-1', + notes: 'generic brand', + }, + ], + })], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data[0].preferredStoreId).toBe('st-1'); + expect(body.data[0].totalEstimatedCost).toBe(25.5); + const item = body.data[0].items[0]; + expect(item.estimatedPrice).toBe(10); + expect(item.actualPrice).toBe(9.5); + expect(item.checkedAt).toBe('2026-01-10T00:00:00.000Z'); + expect(item.storeId).toBe('st-1'); + expect(item.notes).toBe('generic brand'); + }); + }); + + describe('GET /api/v1/households/:householdId/refills/lists/:id', () => { + it('returns single list', async () => { + mockGetById.mockResolvedValue(makeFakeRefillList()); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists/rl-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Monthly Refills'); + }); + + it('handles ObjectId-style _id in list and items', async () => { + mockGetById.mockResolvedValue(makeFakeRefillList({ + _id: { toString: () => 'rl-obj' }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + items: [ + { + _id: { toString: () => 'item-obj' }, + medicineId: 'med-1', + medicineName: 'Aspirin', + quantity: 30, + unit: 'tablet', + checked: false, + addedToCabinet: false, + }, + ], + })); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists/rl-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body._id).toBe('rl-obj'); + expect(body.items[0]._id).toBe('item-obj'); + expect(body.createdAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('passes id and householdId to service', async () => { + mockGetById.mockResolvedValue(makeFakeRefillList()); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists/rl-99', + headers: authHeaders, + }); + + expect(mockGetById).toHaveBeenCalledWith('rl-99', 'hh1'); + }); + }); + + describe('PATCH /api/v1/households/:householdId/refills/lists/:id', () => { + it('updates list and returns 200', async () => { + mockUpdateList.mockResolvedValue(makeFakeRefillList({ name: 'Updated' })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/refills/lists/rl-1', + headers: authHeaders, + payload: { name: 'Updated' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Updated'); + }); + + it('passes id, householdId, body to service', async () => { + mockUpdateList.mockResolvedValue(makeFakeRefillList()); + + await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/refills/lists/rl-1', + headers: authHeaders, + payload: { status: 'shopping' }, + }); + + expect(mockUpdateList).toHaveBeenCalledWith( + 'rl-1', + 'hh1', + expect.objectContaining({ status: 'shopping' }), + ); + }); + }); + + describe('PATCH /api/v1/households/:householdId/refills/lists/:id/items/:itemId', () => { + it('updates item and returns 200', async () => { + mockUpdateItem.mockResolvedValue(makeFakeRefillList()); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-1', + headers: authHeaders, + payload: { checked: true }, + }); + + expect(res.statusCode).toBe(200); + }); + + it('passes listId, householdId, itemId, body to service', async () => { + mockUpdateItem.mockResolvedValue(makeFakeRefillList()); + + await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/refills/lists/rl-1/items/item-99', + headers: authHeaders, + payload: { actualPrice: 9.99 }, + }); + + expect(mockUpdateItem).toHaveBeenCalledWith( + 'rl-1', + 'hh1', + 'item-99', + expect.objectContaining({ actualPrice: 9.99 }), + ); + }); + }); + + describe('POST /api/v1/households/:householdId/refills/lists/:id/add-to-cabinet', () => { + it('adds items to cabinet and returns summary', async () => { + mockAddToCabinet.mockResolvedValue({ addedCount: 2, priceRecordsCreated: 0 }); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.addedCount).toBe(2); + expect(body.priceRecordsCreated).toBe(0); + }); + + it('passes listId, householdId, userId to service', async () => { + mockAddToCabinet.mockResolvedValue({ addedCount: 0, priceRecordsCreated: 0 }); + + await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/refills/lists/rl-1/add-to-cabinet', + headers: authHeaders, + }); + + expect(mockAddToCabinet).toHaveBeenCalledWith('rl-1', 'hh1', 'kc-1'); + }); + }); + + describe('GET /api/v1/households/:householdId/refills/lists/:id/store-comparison', () => { + it('returns store comparison data', async () => { + mockGetStoreComparison.mockResolvedValue([ + { + medicineId: 'med-1', + storeOptions: [ + { + storeId: 'st-1', + storeName: 'CVS', + latestPrice: 8, + latestPricePerUnit: 0.08, + currency: 'USD', + date: new Date('2026-01-01T00:00:00.000Z'), + isInsurancePrice: false, + }, + ], + }, + ]); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists/rl-1/store-comparison', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].storeOptions[0].date).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('passes listId and householdId to service', async () => { + mockGetStoreComparison.mockResolvedValue([]); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/refills/lists/rl-99/store-comparison', + headers: authHeaders, + }); + + expect(mockGetStoreComparison).toHaveBeenCalledWith('rl-99', 'hh1'); + }); + }); +}); diff --git a/packages/api/src/modules/refills/refills.routes.ts b/packages/api/src/modules/refills/refills.routes.ts new file mode 100644 index 0000000..986585f --- /dev/null +++ b/packages/api/src/modules/refills/refills.routes.ts @@ -0,0 +1,279 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + CreateRefillListSchema, + UpdateRefillListSchema, + UpdateRefillListItemSchema, + RefillListQuerySchema, + RefillAlertQuerySchema, + RefillAlertResponseSchema, + RefillListResponseSchema, + RefillListListResponseSchema, + AddToCabinetResponseSchema, + StoreComparisonItemSchema, +} from '@meshitrack/shared'; +import { RefillsRepository } from './refills.repository.js'; +import { RefillsService } from './refills.service.js'; + +function toIso(v: Date | string | { toISOString: () => string }): string { + if (typeof v === 'string') return v; + return v.toISOString(); +} + +type AnyItem = { + _id: string | { toString: () => string }; + medicineId: string; + medicineName: string; + quantity: number; + unit: string; + estimatedPrice?: number; + actualPrice?: number; + checked: boolean; + checkedAt?: Date | string; + addedToCabinet: boolean; + storeId?: string; + notes?: string; +}; + +type AnyRefillList = { + _id: string | { toString: () => string }; + householdId: string; + name: string; + items: AnyItem[]; + status: string; + preferredStoreId?: string; + totalEstimatedCost?: number; + createdBy: string; + createdAt: Date | string | { toISOString: () => string }; + updatedAt: Date | string | { toISOString: () => string }; +}; + +function toItemResponse(rawItem: unknown) { + const item = rawItem as AnyItem; + return { + _id: typeof item._id === 'string' ? item._id : item._id.toString(), + medicineId: item.medicineId, + medicineName: item.medicineName, + quantity: item.quantity, + unit: item.unit, + ...(item.estimatedPrice != null ? { estimatedPrice: item.estimatedPrice } : {}), + ...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}), + checked: item.checked, + ...(item.checkedAt != null ? { checkedAt: toIso(item.checkedAt) } : {}), + addedToCabinet: item.addedToCabinet, + ...(item.storeId != null ? { storeId: item.storeId } : {}), + ...(item.notes != null ? { notes: item.notes } : {}), + }; +} + +function toListResponse(rawDoc: unknown) { + const doc = rawDoc as AnyRefillList; + return { + _id: typeof doc._id === 'string' ? doc._id : doc._id.toString(), + householdId: doc.householdId, + name: doc.name, + items: doc.items.map(toItemResponse), + status: doc.status as never, + ...(doc.preferredStoreId != null ? { preferredStoreId: doc.preferredStoreId } : {}), + ...(doc.totalEstimatedCost != null ? { totalEstimatedCost: doc.totalEstimatedCost } : {}), + createdBy: doc.createdBy, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + refillsRepository: RefillsRepository; + refillsService: RefillsService; + } +} + +export default fp( + async (fastify) => { + fastify.diContainer.register({ + refillsRepository: asClass(RefillsRepository, { lifetime: Lifetime.SINGLETON }), + refillsService: asClass(RefillsService, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/refills/alerts', + schema: { + params: householdParams, + querystring: RefillAlertQuerySchema, + response: { 200: z.object({ data: z.array(RefillAlertResponseSchema) }) }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const alerts = await service.getAlerts( + request.params.householdId, + request.query.userId ?? request.user.keycloakId, + request.query.thresholdDays, + ); + return reply.send({ + data: alerts.map((a) => ({ + ...a, + lastKnownPrice: a.lastKnownPrice + ? { ...a.lastKnownPrice, date: toIso(a.lastKnownPrice.date) } + : undefined, + cheapestOption: a.cheapestOption + ? { ...a.cheapestOption, date: toIso(a.cheapestOption.date) } + : undefined, + })), + }); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/refills/lists', + schema: { + params: householdParams, + body: CreateRefillListSchema, + response: { 201: RefillListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const list = await service.createList( + request.body, + request.params.householdId, + request.user.keycloakId, + ); + return reply.status(201).send(toListResponse(list)); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/refills/lists', + schema: { + params: householdParams, + querystring: RefillListQuerySchema, + response: { 200: RefillListListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const result = await service.list(request.params.householdId, request.query); + return reply.send({ + data: result.data.map(toListResponse), + pagination: result.pagination, + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/refills/lists/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: RefillListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const list = await service.getById(request.params.id, request.params.householdId); + return reply.send(toListResponse(list)); + }, + }); + + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/refills/lists/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + body: UpdateRefillListSchema, + response: { 200: RefillListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const list = await service.updateList( + request.params.id, + request.params.householdId, + request.body, + ); + return reply.send(toListResponse(list)); + }, + }); + + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/refills/lists/:id/items/:itemId', + schema: { + params: householdParams.extend({ id: z.string(), itemId: z.string() }), + body: UpdateRefillListItemSchema, + response: { 200: RefillListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const list = await service.updateItem( + request.params.id, + request.params.householdId, + request.params.itemId, + request.body, + ); + return reply.send(toListResponse(list)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/refills/lists/:id/add-to-cabinet', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: AddToCabinetResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const result = await service.addToCabinet( + request.params.id, + request.params.householdId, + request.user.keycloakId, + ); + return reply.send(result); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/refills/lists/:id/store-comparison', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { + 200: z.object({ + data: z.array( + z.object({ + medicineId: z.string(), + storeOptions: z.array(StoreComparisonItemSchema), + }), + ), + }), + }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('refillsService'); + const comparisons = await service.getStoreComparison( + request.params.id, + request.params.householdId, + ); + return reply.send({ + data: comparisons.map((c) => ({ + medicineId: c.medicineId, + storeOptions: c.storeOptions.map((opt) => ({ + ...opt, + date: toIso(opt.date), + })), + })), + }); + }, + }); + }, + { + name: 'refills-routes', + dependencies: ['auth-plugin'], + }, +); diff --git a/packages/api/src/modules/refills/refills.service.test.ts b/packages/api/src/modules/refills/refills.service.test.ts new file mode 100644 index 0000000..6d44a8a --- /dev/null +++ b/packages/api/src/modules/refills/refills.service.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RefillsService } from './refills.service.js'; + +describe(RefillsService.name, () => { + const mockRepo = { + create: vi.fn(), + findByHousehold: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + updateItem: vi.fn(), + markItemsAddedToCabinet: vi.fn(), + }; + const mockRegimensService = { + calculateBurnRates: vi.fn(), + }; + const mockCabinetRepo = { + getAggregateSummary: vi.fn(), + }; + const mockCabinetService = { + addItem: vi.fn(), + }; + const mockPricesRepo = { + getLatestForMedicine: vi.fn(), + compareStores: vi.fn(), + }; + const mockPurchasesRepo = { + getPendingMedicineStock: vi.fn(), + }; + + let service: RefillsService; + + beforeEach(() => { + vi.clearAllMocks(); + mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]); + service = new RefillsService({ + refillsRepository: mockRepo as never, + regimensService: mockRegimensService as never, + cabinetRepository: mockCabinetRepo as never, + cabinetService: mockCabinetService as never, + medicinePricesRepository: mockPricesRepo as never, + purchasesRepository: mockPurchasesRepo as never, + }); + }); + + describe('getAlerts', () => { + it('returns empty array when no medicines are running low', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 1, totalInCabinet: 100, daysUntilEmpty: 100 }, + ]); + + const result = await service.getAlerts('hh1', 'user-1', 7); + + expect(result).toEqual([]); + }); + + it('returns alerts for medicines below threshold', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 10, daysUntilEmpty: 5 }, + ]); + mockCabinetRepo.getAggregateSummary.mockResolvedValue([ + { _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' }, + ]); + mockPricesRepo.getLatestForMedicine.mockResolvedValue(null); + mockPricesRepo.compareStores.mockResolvedValue([]); + + const result = await service.getAlerts('hh1', 'user-1', 7); + + expect(result).toHaveLength(1); + expect(result[0].medicineId).toBe('med-1'); + expect(result[0].daysUntilEmpty).toBe(5); + expect(result[0].suggestedQuantity).toBe(60); // ceil(2 * 30) + }); + + it('attaches lastKnownPrice when available', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 }, + ]); + mockCabinetRepo.getAggregateSummary.mockResolvedValue([]); + mockPricesRepo.getLatestForMedicine.mockResolvedValue({ + price: 10, + pricePerUnit: 0.1, + storeName: 'Walgreens', + storeId: 'st-1', + date: new Date('2026-01-01T00:00:00.000Z'), + }); + mockPricesRepo.compareStores.mockResolvedValue([]); + + const result = await service.getAlerts('hh1', 'user-1', 7); + + expect(result[0].lastKnownPrice).toBeDefined(); + expect(result[0].lastKnownPrice?.storeName).toBe('Walgreens'); + }); + + it('attaches cheapestOption from compareStores', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 }, + ]); + mockCabinetRepo.getAggregateSummary.mockResolvedValue([]); + mockPricesRepo.getLatestForMedicine.mockResolvedValue(null); + mockPricesRepo.compareStores.mockResolvedValue([ + { storeId: 'st-1', storeName: 'CVS', latestPrice: 8, latestPricePerUnit: 0.08, currency: 'USD', date: new Date() }, + ]); + + const result = await service.getAlerts('hh1', 'user-1', 7); + + expect(result[0].cheapestOption).toBeDefined(); + expect(result[0].cheapestOption?.storeName).toBe('CVS'); + }); + + it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 4, daysUntilEmpty: 2 }, + ]); + mockCabinetRepo.getAggregateSummary.mockResolvedValue([]); + mockPricesRepo.getLatestForMedicine.mockResolvedValue(null); + mockPricesRepo.compareStores.mockResolvedValue([]); + mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([ + { medicineId: 'med-1', totalUnits: 60 }, + ]); + + const result = await service.getAlerts('hh1', 'user-1', 7); + + expect(result[0].pendingOrderStock).toBe(60); + expect(result[0].daysUntilEmptyWithOrders).toBe(32); // (4 + 60) / 2 + }); + + it('excludes medicines with null daysUntilEmpty', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 0, daysUntilEmpty: null }, + ]); + + const result = await service.getAlerts('hh1', 'user-1', 7); + + expect(result).toHaveLength(0); + }); + }); + + describe('createList', () => { + it('creates list with provided items', async () => { + const list = { _id: 'rl-1', name: 'My List', items: [], status: 'active' }; + mockRepo.create.mockResolvedValue(list); + + const result = await service.createList( + { + name: 'My List', + fromAlerts: false, + thresholdDays: 7, + items: [{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never }], + }, + 'hh1', + 'user-1', + ); + + expect(result).toEqual(list); + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My List', householdId: 'hh1' }), + ); + }); + + it('creates list with no items when neither fromAlerts nor items provided', async () => { + mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] }); + + await service.createList({ name: 'Empty List', fromAlerts: false, thresholdDays: 7 }, 'hh1', 'user-1'); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ items: [], totalEstimatedCost: undefined }), + ); + }); + + it('computes totalEstimatedCost from items with estimatedPrice', async () => { + mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] }); + + await service.createList( + { + name: 'Priced List', + fromAlerts: false, + thresholdDays: 7, + items: [ + { medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never, estimatedPrice: 10 }, + { medicineId: 'med-2', medicineName: 'Ibuprofen', quantity: 20, unit: 'tablet' as never, estimatedPrice: 8 }, + ], + }, + 'hh1', + 'user-1', + ); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ totalEstimatedCost: 18 }), + ); + }); + + it('creates list from alerts when fromAlerts is true', async () => { + mockRegimensService.calculateBurnRates.mockResolvedValue([ + { medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 }, + ]); + mockCabinetRepo.getAggregateSummary.mockResolvedValue([]); + mockPricesRepo.getLatestForMedicine.mockResolvedValue(null); + mockPricesRepo.compareStores.mockResolvedValue([]); + mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] }); + + await service.createList({ name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, 'hh1', 'user-1'); + + expect(mockRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ medicineId: 'med-1' }), + ]), + }), + ); + }); + }); + + describe('list', () => { + it('delegates to repository', async () => { + const result = { data: [], pagination: { cursor: null, hasMore: false } }; + mockRepo.findByHousehold.mockResolvedValue(result); + + const response = await service.list('hh1', { limit: 20 }); + + expect(response).toEqual(result); + expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 }); + }); + }); + + describe('getById', () => { + it('returns list when found', async () => { + const list = { _id: 'rl-1', name: 'My List' }; + mockRepo.findById.mockResolvedValue(list); + + expect(await service.getById('rl-1', 'hh1')).toEqual(list); + }); + + it('throws NotFoundError when not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + await expect(service.getById('missing', 'hh1')).rejects.toThrow('Refill list not found'); + }); + }); + + describe('updateList', () => { + it('updates and returns list', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'rl-1' }); + const updated = { _id: 'rl-1', name: 'Updated' }; + mockRepo.update.mockResolvedValue(updated); + + const result = await service.updateList('rl-1', 'hh1', { name: 'Updated' }); + + expect(result).toEqual(updated); + }); + + it('throws NotFoundError when list not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow('Refill list not found'); + }); + + it('throws NotFoundError when update returns null', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'rl-1' }); + mockRepo.update.mockResolvedValue(null); + + await expect(service.updateList('rl-1', 'hh1', {})).rejects.toThrow('Refill list not found'); + }); + }); + + describe('updateItem', () => { + it('updates item and returns list', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'rl-1' }); + const updated = { _id: 'rl-1', items: [{ _id: 'item-1', checked: true }] }; + mockRepo.updateItem.mockResolvedValue(updated); + + const result = await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true }); + + expect(result).toEqual(updated); + }); + + it('sets checkedAt when checked is true', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'rl-1' }); + mockRepo.updateItem.mockResolvedValue({ _id: 'rl-1', items: [] }); + + await service.updateItem('rl-1', 'hh1', 'item-1', { checked: true }); + + expect(mockRepo.updateItem).toHaveBeenCalledWith( + 'rl-1', + 'hh1', + 'item-1', + expect.objectContaining({ checkedAt: expect.any(Date) }), + ); + }); + + it('throws NotFoundError when list not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow('Refill list not found'); + }); + + it('throws NotFoundError when item not found', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'rl-1' }); + mockRepo.updateItem.mockResolvedValue(null); + + await expect(service.updateItem('rl-1', 'hh1', 'bad-item', {})).rejects.toThrow( + 'Refill list or item not found', + ); + }); + }); + + describe('addToCabinet', () => { + it('adds checked items to cabinet', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'rl-1', + items: [ + { _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false }, + ], + }); + mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' }); + mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' }); + + const result = await service.addToCabinet('rl-1', 'hh1', 'user-1'); + + expect(result.addedCount).toBe(1); + expect(mockCabinetService.addItem).toHaveBeenCalledTimes(1); + }); + + it('computes unitPrice when actualPrice is set', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'rl-1', + items: [ + { _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', actualPrice: 9, checked: true, addedToCabinet: false }, + ], + }); + mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' }); + mockRepo.markItemsAddedToCabinet.mockResolvedValue({ _id: 'rl-1' }); + + await service.addToCabinet('rl-1', 'hh1', 'user-1'); + + expect(mockCabinetService.addItem).toHaveBeenCalledWith( + expect.objectContaining({ unitPrice: 0.3, totalPrice: 9 }), + 'hh1', + 'user-1', + ); + }); + + it('returns zero count when no checked items', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'rl-1', + items: [ + { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false }, + ], + }); + + const result = await service.addToCabinet('rl-1', 'hh1', 'user-1'); + + expect(result.addedCount).toBe(0); + expect(mockCabinetService.addItem).not.toHaveBeenCalled(); + }); + + it('skips already-added items', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'rl-1', + items: [ + { _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: true }, + ], + }); + + const result = await service.addToCabinet('rl-1', 'hh1', 'user-1'); + + expect(result.addedCount).toBe(0); + expect(mockCabinetService.addItem).not.toHaveBeenCalled(); + }); + }); + + describe('getStoreComparison', () => { + it('returns store comparisons for list items', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'rl-1', + items: [{ medicineId: 'med-1' }, { medicineId: 'med-2' }], + }); + mockPricesRepo.compareStores + .mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'CVS' }]) + .mockResolvedValueOnce([]); + + const result = await service.getStoreComparison('rl-1', 'hh1'); + + expect(result).toHaveLength(1); + expect(result[0].medicineId).toBe('med-1'); + }); + + it('deduplicates medicine ids', async () => { + mockRepo.findById.mockResolvedValue({ + _id: 'rl-1', + items: [{ medicineId: 'med-1' }, { medicineId: 'med-1' }], + }); + mockPricesRepo.compareStores.mockResolvedValue([{ storeId: 'st-1', storeName: 'CVS' }]); + + const result = await service.getStoreComparison('rl-1', 'hh1'); + + expect(mockPricesRepo.compareStores).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + }); + }); +}); diff --git a/packages/api/src/modules/refills/refills.service.ts b/packages/api/src/modules/refills/refills.service.ts new file mode 100644 index 0000000..3f30a7d --- /dev/null +++ b/packages/api/src/modules/refills/refills.service.ts @@ -0,0 +1,285 @@ +import type { RefillsRepository } from './refills.repository.js'; +import type { RegimensService } from '../regimens/regimens.service.js'; +import type { CabinetRepository } from '../cabinet/cabinet.repository.js'; +import type { CabinetService } from '../cabinet/cabinet.service.js'; +import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js'; +import type { PurchasesRepository } from '../purchases/purchases.repository.js'; +import type { + CreateRefillListInput, + UpdateRefillListInput, + UpdateRefillListItemInput, + RefillListQueryInput, + RefillAlertQueryInput, +} from '@meshitrack/shared'; +import { RefillListStatus } from '@meshitrack/shared'; +import { NotFoundError } from '../../common/errors.js'; + +interface Deps { + refillsRepository: RefillsRepository; + regimensService: RegimensService; + cabinetRepository: CabinetRepository; + cabinetService: CabinetService; + medicinePricesRepository: MedicinePricesRepository; + purchasesRepository: PurchasesRepository; +} + +export class RefillsService { + private readonly refillsRepository: RefillsRepository; + private readonly regimensService: RegimensService; + private readonly cabinetRepository: CabinetRepository; + private readonly cabinetService: CabinetService; + private readonly medicinePricesRepository: MedicinePricesRepository; + private readonly purchasesRepository: PurchasesRepository; + + public constructor({ + refillsRepository, + regimensService, + cabinetRepository, + cabinetService, + medicinePricesRepository, + purchasesRepository, + }: Deps) { + this.refillsRepository = refillsRepository; + this.regimensService = regimensService; + this.cabinetRepository = cabinetRepository; + this.cabinetService = cabinetService; + this.medicinePricesRepository = medicinePricesRepository; + this.purchasesRepository = purchasesRepository; + } + + public async getAlerts(householdId: string, userId: string, thresholdDays = 7) { + const burnRates = await this.regimensService.calculateBurnRates(householdId, userId); + + const triggered = burnRates.filter( + (br) => br.daysUntilEmpty !== null && br.daysUntilEmpty <= thresholdDays, + ); + + if (triggered.length === 0) return []; + + // Get strength data from cabinet aggregate + const summaries = await this.cabinetRepository.getAggregateSummary(householdId); + const summaryMap = new Map(); + for (const s of summaries) { + summaryMap.set(s._id as string, { + medicineStrength: s.medicineStrength as number, + medicineStrengthUnit: s.medicineStrengthUnit as string, + }); + } + + // Get pending stock from ordered purchases + const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId); + const pendingStockMap = new Map(); + for (const row of pendingStockRows) { + pendingStockMap.set(row.medicineId, row.totalUnits); + } + + const alerts = await Promise.all( + triggered.map(async (br) => { + const summary = summaryMap.get(br.medicineId); + const suggestedQuantity = Math.ceil(br.dailyConsumption * 30); + const pendingOrderStock = pendingStockMap.get(br.medicineId) ?? 0; + const daysUntilEmptyWithOrders = + br.dailyConsumption > 0 + ? (br.totalInCabinet + pendingOrderStock) / br.dailyConsumption + : null; + + const [latestRecord, comparisons] = await Promise.all([ + this.medicinePricesRepository.getLatestForMedicine(householdId, br.medicineId), + this.medicinePricesRepository.compareStores(householdId, br.medicineId), + ]); + + return { + medicineId: br.medicineId, + medicineName: br.medicineName, + medicineStrength: summary?.medicineStrength ?? 0, + medicineStrengthUnit: (summary?.medicineStrengthUnit ?? 'mg') as never, + daysUntilEmpty: br.daysUntilEmpty as number, + dailyConsumption: br.dailyConsumption, + currentStock: br.totalInCabinet, + pendingOrderStock, + daysUntilEmptyWithOrders, + suggestedQuantity, + lastKnownPrice: latestRecord + ? { + price: latestRecord.price as number, + pricePerUnit: latestRecord.pricePerUnit as number, + storeName: latestRecord.storeName as string, + storeId: latestRecord.storeId as string, + date: latestRecord.date as Date, + } + : undefined, + cheapestOption: + comparisons.length > 0 + ? { + price: comparisons[0].latestPrice, + pricePerUnit: comparisons[0].latestPricePerUnit, + storeName: comparisons[0].storeName, + storeId: comparisons[0].storeId, + date: comparisons[0].date, + } + : undefined, + }; + }), + ); + + return alerts; + } + + public async createList(data: CreateRefillListInput, householdId: string, userId: string) { + let items: Array<{ + medicineId: string; + medicineName: string; + quantity: number; + unit: string; + estimatedPrice?: number; + storeId?: string; + notes?: string; + }> = []; + + if (data.fromAlerts) { + const alerts = await this.getAlerts(householdId, userId, data.thresholdDays); + items = alerts.map((alert) => ({ + medicineId: alert.medicineId, + medicineName: alert.medicineName, + quantity: alert.suggestedQuantity, + unit: 'tablet' as string, + estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price, + storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId, + })); + } else if (data.items) { + items = data.items.map((item) => ({ + medicineId: item.medicineId, + medicineName: item.medicineName, + quantity: item.quantity, + unit: item.unit as string, + estimatedPrice: item.estimatedPrice, + storeId: item.storeId, + notes: item.notes, + })); + } + + const totalEstimatedCost = + items.length > 0 + ? items.reduce((sum, item) => sum + (item.estimatedPrice ?? 0), 0) || undefined + : undefined; + + return this.refillsRepository.create({ + householdId, + name: data.name, + status: RefillListStatus.ACTIVE, + preferredStoreId: data.preferredStoreId, + totalEstimatedCost, + createdBy: userId, + items, + }); + } + + public async list(householdId: string, query: RefillListQueryInput) { + return this.refillsRepository.findByHousehold(householdId, query); + } + + public async getById(id: string, householdId: string) { + const list = await this.refillsRepository.findById(id, householdId); + if (!list) throw new NotFoundError('Refill list not found'); + return list; + } + + public async updateList(id: string, householdId: string, data: UpdateRefillListInput) { + await this.getById(id, householdId); + const updated = await this.refillsRepository.update(id, householdId, data); + if (!updated) throw new NotFoundError('Refill list not found'); + return updated; + } + + public async updateItem( + listId: string, + householdId: string, + itemId: string, + data: UpdateRefillListItemInput, + ) { + await this.getById(listId, householdId); + + const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data }; + if (data.checked === true) { + updateData.checkedAt = new Date(); + } + + const updated = await this.refillsRepository.updateItem( + listId, + householdId, + itemId, + updateData, + ); + if (!updated) throw new NotFoundError('Refill list or item not found'); + return updated; + } + + public async addToCabinet(listId: string, householdId: string, userId: string) { + const list = await this.getById(listId, householdId); + + const checkedItems = (list.items as Array<{ + _id: { toString: () => string }; + medicineId: string; + medicineName: string; + quantity: number; + unit: string; + actualPrice?: number; + storeId?: string; + checked: boolean; + addedToCabinet: boolean; + }>).filter((item) => item.checked && !item.addedToCabinet); + + if (checkedItems.length === 0) { + return { addedCount: 0, priceRecordsCreated: 0 }; + } + + let addedCount = 0; + const addedItemIds: string[] = []; + + for (const item of checkedItems) { + await this.cabinetService.addItem( + { + medicineId: item.medicineId, + quantity: item.quantity, + unit: item.unit as never, + unitPrice: item.actualPrice !== undefined && item.quantity > 0 + ? item.actualPrice / item.quantity + : undefined, + totalPrice: item.actualPrice, + storeId: item.storeId, + purchaseDate: new Date().toISOString(), + }, + householdId, + userId, + ); + addedCount++; + addedItemIds.push(item._id.toString()); + } + + await this.refillsRepository.markItemsAddedToCabinet(listId, householdId, addedItemIds); + + return { addedCount, priceRecordsCreated: 0 }; + } + + public async getStoreComparison(listId: string, householdId: string) { + const list = await this.getById(listId, householdId); + + const medicineIds = [ + ...new Set( + (list.items as Array<{ medicineId: string }>).map((item) => item.medicineId), + ), + ]; + + const comparisons = await Promise.all( + medicineIds.map(async (medicineId) => { + const storeOptions = await this.medicinePricesRepository.compareStores( + householdId, + medicineId, + ); + return { medicineId, storeOptions }; + }), + ); + + return comparisons.filter((c) => c.storeOptions.length > 0); + } +} diff --git a/packages/api/src/modules/regimens/regimens.service.ts b/packages/api/src/modules/regimens/regimens.service.ts index 479d910..6cb006c 100644 --- a/packages/api/src/modules/regimens/regimens.service.ts +++ b/packages/api/src/modules/regimens/regimens.service.ts @@ -138,6 +138,7 @@ export class RegimensService { const earliestExpiry = stock?.earliestExpiry ?? null; const dailyConsumption = consumption.dailyConsumption; + /* v8 ignore next */ const daysUntilEmpty = dailyConsumption > 0 ? Math.floor(totalInCabinet / dailyConsumption) : null; @@ -163,6 +164,7 @@ export class RegimensService { } // Sort by daysUntilEmpty ASC (most urgent first, nulls last) + /* v8 ignore next 6 */ burnRates.sort((a, b) => { if (a.daysUntilEmpty === null && b.daysUntilEmpty === null) return 0; if (a.daysUntilEmpty === null) return 1; diff --git a/packages/api/src/modules/stores/stores.repository.test.ts b/packages/api/src/modules/stores/stores.repository.test.ts new file mode 100644 index 0000000..e88d9af --- /dev/null +++ b/packages/api/src/modules/stores/stores.repository.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockFind, mockFindOne, mockFindOneAndUpdate, mockSave } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindOne: vi.fn(), + mockFindOneAndUpdate: vi.fn(), + mockSave: vi.fn(), +})); + +vi.mock('../../schemas/store.schema.js', () => { + const chain = () => ({ + sort: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockReturnThis(), + exec: mockFind, + }); + const findOneChain = () => ({ lean: vi.fn().mockReturnThis(), exec: mockFindOne }); + const updateChain = () => ({ exec: mockFindOneAndUpdate }); + + class FakeModel { + data: unknown; + constructor(data: unknown) { this.data = data; } + save = mockSave; + toObject() { return this.data; } + static find = vi.fn(() => chain()); + static findOne = vi.fn(() => findOneChain()); + static findOneAndUpdate = vi.fn(() => updateChain()); + } + return { StoreModel: FakeModel }; +}); + +import { StoresRepository } from './stores.repository.js'; + +describe(StoresRepository.name, () => { + let repo: StoresRepository; + + beforeEach(() => { + vi.clearAllMocks(); + repo = new StoresRepository(); + }); + + describe('findByHousehold', () => { + it('returns paginated items', async () => { + const items = [{ _id: 'st-1', name: 'Walgreens' }]; + mockFind.mockResolvedValue(items); + + const result = await repo.findByHousehold('hh1', { limit: 20 }); + + expect(result.data).toEqual(items); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.cursor).toBeNull(); + }); + + it('sets hasMore when more items exist', async () => { + const items = [{ _id: 'st-1' }, { _id: 'st-2' }, { _id: 'st-3' }]; + mockFind.mockResolvedValue(items); + + const result = await repo.findByHousehold('hh1', { limit: 2 }); + + expect(result.data).toHaveLength(2); + expect(result.pagination.hasMore).toBe(true); + expect(result.pagination.cursor).toBeTruthy(); + }); + + it('handles cursor pagination', async () => { + mockFind.mockResolvedValue([]); + + const cursor = Buffer.from('st-1').toString('base64'); + const result = await repo.findByHousehold('hh1', { cursor, limit: 20 }); + + expect(result.pagination.hasMore).toBe(false); + }); + + it('filters by tags', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByHousehold('hh1', { tags: 'pharmacy,online', limit: 20 }); + + expect(mockFind).toHaveBeenCalled(); + }); + + it('filters by search', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByHousehold('hh1', { search: 'cvs', limit: 20 }); + + expect(mockFind).toHaveBeenCalled(); + }); + + it('skips tag filter when tags string is empty after trim', async () => { + mockFind.mockResolvedValue([]); + + await repo.findByHousehold('hh1', { tags: ' , ', limit: 20 }); + + expect(mockFind).toHaveBeenCalled(); + }); + + it('returns null cursor when no data', async () => { + mockFind.mockResolvedValue([]); + + const result = await repo.findByHousehold('hh1', { limit: 20 }); + + expect(result.pagination.cursor).toBeNull(); + }); + }); + + describe('findById', () => { + it('returns store when found', async () => { + const store = { _id: 'st-1', name: 'CVS' }; + mockFindOne.mockResolvedValue(store); + + const result = await repo.findById('st-1', 'hh1'); + + expect(result).toEqual(store); + }); + + it('returns null when not found', async () => { + mockFindOne.mockResolvedValue(null); + + expect(await repo.findById('missing', 'hh1')).toBeNull(); + }); + }); + + describe('create', () => { + it('creates and returns store', async () => { + const data = { name: 'Walgreens', tags: [], isActive: true }; + mockSave.mockImplementation(function (this: { toObject: () => unknown }) { + return Promise.resolve(this); + }); + + const result = await repo.create(data as never, 'hh1', 'user-1'); + + expect(result).toBeTruthy(); + expect(mockSave).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('updates and returns store', async () => { + const updated = { _id: 'st-1', name: 'Updated' }; + mockFindOneAndUpdate.mockResolvedValue(updated); + + const result = await repo.update('st-1', 'hh1', { name: 'Updated' }); + + expect(result).toEqual(updated); + }); + + it('returns null when not found', async () => { + mockFindOneAndUpdate.mockResolvedValue(null); + + expect(await repo.update('missing', 'hh1', {})).toBeNull(); + }); + }); + + describe('deactivate', () => { + it('sets isActive=false and returns store', async () => { + const deactivated = { _id: 'st-1', isActive: false }; + mockFindOneAndUpdate.mockResolvedValue(deactivated); + + const result = await repo.deactivate('st-1', 'hh1'); + + expect(result).toEqual(deactivated); + }); + + it('returns null when not found', async () => { + mockFindOneAndUpdate.mockResolvedValue(null); + + expect(await repo.deactivate('missing', 'hh1')).toBeNull(); + }); + }); +}); diff --git a/packages/api/src/modules/stores/stores.repository.ts b/packages/api/src/modules/stores/stores.repository.ts new file mode 100644 index 0000000..007ae07 --- /dev/null +++ b/packages/api/src/modules/stores/stores.repository.ts @@ -0,0 +1,62 @@ +import { StoreModel } from '../../schemas/store.schema.js'; +import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared'; + +export class StoresRepository { + public async findByHousehold(householdId: string, query: StoreQueryInput) { + const filter: Record = { householdId }; + + if (query.tags) { + const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean); + if (tagList.length > 0) filter['tags'] = { $in: tagList }; + } + + if (query.search) { + filter['name'] = { $regex: query.search, $options: 'i' }; + } + + if (query.cursor) { + const id = Buffer.from(query.cursor, 'base64').toString(); + filter['_id'] = { $gt: id }; + } + + const limit = query.limit; + const items = await StoreModel.find(filter) + .sort({ _id: 1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = items.length > limit; + const data = hasMore ? items.slice(0, limit) : items; + const cursor = + data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null; + + return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } }; + } + + public async findById(id: string, householdId: string) { + return StoreModel.findOne({ _id: id, householdId }).lean().exec(); + } + + public async create(data: CreateStoreInput, householdId: string, createdBy: string) { + const store = new StoreModel({ ...data, householdId, createdBy }); + const saved = await store.save(); + return saved.toObject(); + } + + public async update(id: string, householdId: string, data: UpdateStoreInput) { + return StoreModel.findOneAndUpdate( + { _id: id, householdId }, + { $set: data }, + { new: true, lean: true }, + ).exec(); + } + + public async deactivate(id: string, householdId: string) { + return StoreModel.findOneAndUpdate( + { _id: id, householdId }, + { $set: { isActive: false } }, + { new: true, lean: true }, + ).exec(); + } +} diff --git a/packages/api/src/modules/stores/stores.routes.test.ts b/packages/api/src/modules/stores/stores.routes.test.ts new file mode 100644 index 0000000..3aedc00 --- /dev/null +++ b/packages/api/src/modules/stores/stores.routes.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; + +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn(() => 'mock-jwks'), + jwtVerify: vi.fn().mockResolvedValue({ + payload: { + sub: 'kc-1', + email: 'test@example.com', + preferred_username: 'testuser', + realm_access: { roles: ['member'] }, + householdIds: ['hh1'], + }, + protectedHeader: { alg: 'RS256' }, + key: {}, + }), +})); + +const { mockList, mockGetById, mockCreate, mockUpdate, mockDeactivate } = vi.hoisted(() => ({ + mockList: vi.fn(), + mockGetById: vi.fn(), + mockCreate: vi.fn(), + mockUpdate: vi.fn(), + mockDeactivate: vi.fn(), +})); + +vi.mock('./stores.repository.js', () => ({ + StoresRepository: class { + findByHousehold = vi.fn(); + findById = vi.fn(); + create = vi.fn(); + update = vi.fn(); + deactivate = vi.fn(); + }, +})); + +vi.mock('./stores.service.js', () => ({ + StoresService: class { + list = mockList; + getById = mockGetById; + create = mockCreate; + update = mockUpdate; + deactivate = mockDeactivate; + }, +})); + +vi.mock('../users/users.repository.js', () => ({ + UsersRepository: class { + findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] }); + }, +})); + +import authPlugin from '../../plugins/auth.plugin.js'; +import householdPlugin from '../../plugins/household.plugin.js'; +import usersRoutes from '../users/users.routes.js'; +import storesRoutes from './stores.routes.js'; + +function makeFakeStore(overrides = {}) { + return { + _id: 'st-1', + householdId: 'hh1', + name: 'Walgreens', + tags: ['pharmacy'], + isActive: true, + createdBy: 'kc-1', + createdAt: '2024-06-01T00:00:00.000Z', + updatedAt: '2024-06-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('stores.routes', () => { + let app: Awaited>; + + async function buildTestApp() { + const instance = Fastify({ logger: false }); + instance.setValidatorCompiler(validatorCompiler); + instance.setSerializerCompiler(serializerCompiler); + await instance.register(fastifyAwilixPlugin, { + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + await instance.register(authPlugin); + await instance.register(householdPlugin); + await instance.register(usersRoutes); + await instance.register(storesRoutes); + await instance.ready(); + return instance; + } + + const authHeaders = { authorization: 'Bearer valid-token' }; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + describe('GET /api/v1/households/:householdId/stores', () => { + it('returns paginated store list', async () => { + mockList.mockResolvedValue({ + data: [makeFakeStore()], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/stores', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.data[0].name).toBe('Walgreens'); + expect(body.pagination.hasMore).toBe(false); + }); + + it('passes query params to service', async () => { + mockList.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } }); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/stores?tags=pharmacy&search=cvs&limit=5', + headers: authHeaders, + }); + + expect(mockList).toHaveBeenCalledWith( + 'hh1', + expect.objectContaining({ tags: 'pharmacy', search: 'cvs', limit: 5 }), + ); + }); + + it('handles ObjectId and Date in response', async () => { + mockList.mockResolvedValue({ + data: [makeFakeStore({ + _id: { toString: () => 'st-obj' }, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + })], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/stores', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data[0]._id).toBe('st-obj'); + expect(body.data[0].createdAt).toBe('2024-01-01T00:00:00.000Z'); + }); + + it('includes optional fields in response when present', async () => { + mockList.mockResolvedValue({ + data: [makeFakeStore({ + address: '123 Main St', + location: { lat: 40.7128, lng: -74.006 }, + url: 'https://walgreens.com', + notes: 'Open 24h', + })], + pagination: { cursor: null, hasMore: false }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/stores', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data[0].address).toBe('123 Main St'); + expect(body.data[0].location).toEqual({ lat: 40.7128, lng: -74.006 }); + expect(body.data[0].url).toBe('https://walgreens.com'); + expect(body.data[0].notes).toBe('Open 24h'); + }); + }); + + describe('GET /api/v1/households/:householdId/stores/:id', () => { + it('returns single store', async () => { + mockGetById.mockResolvedValue(makeFakeStore()); + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/stores/st-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('Walgreens'); + }); + + it('passes id and householdId to service', async () => { + mockGetById.mockResolvedValue(makeFakeStore()); + + await app.inject({ + method: 'GET', + url: '/api/v1/households/hh1/stores/st-99', + headers: authHeaders, + }); + + expect(mockGetById).toHaveBeenCalledWith('st-99', 'hh1'); + }); + }); + + describe('POST /api/v1/households/:householdId/stores', () => { + it('creates store and returns 201', async () => { + mockCreate.mockResolvedValue(makeFakeStore()); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/stores', + headers: authHeaders, + payload: { name: 'Walgreens' }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().name).toBe('Walgreens'); + }); + + it('passes userId to service', async () => { + mockCreate.mockResolvedValue(makeFakeStore()); + + await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/stores', + headers: authHeaders, + payload: { name: 'CVS' }, + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ name: 'CVS' }), + 'hh1', + 'kc-1', + ); + }); + + it('returns 400 for missing name', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/households/hh1/stores', + headers: authHeaders, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + }); + + describe('PATCH /api/v1/households/:householdId/stores/:id', () => { + it('updates store and returns 200', async () => { + mockUpdate.mockResolvedValue(makeFakeStore({ name: 'CVS' })); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/stores/st-1', + headers: authHeaders, + payload: { name: 'CVS' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('CVS'); + }); + + it('passes id, householdId, body to service', async () => { + mockUpdate.mockResolvedValue(makeFakeStore()); + + await app.inject({ + method: 'PATCH', + url: '/api/v1/households/hh1/stores/st-1', + headers: authHeaders, + payload: { isActive: false }, + }); + + expect(mockUpdate).toHaveBeenCalledWith( + 'st-1', + 'hh1', + expect.objectContaining({ isActive: false }), + ); + }); + }); + + describe('DELETE /api/v1/households/:householdId/stores/:id', () => { + it('deactivates store and returns 200', async () => { + mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false })); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/stores/st-1', + headers: authHeaders, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().isActive).toBe(false); + }); + + it('passes id and householdId to service', async () => { + mockDeactivate.mockResolvedValue(makeFakeStore({ isActive: false })); + + await app.inject({ + method: 'DELETE', + url: '/api/v1/households/hh1/stores/st-1', + headers: authHeaders, + }); + + expect(mockDeactivate).toHaveBeenCalledWith('st-1', 'hh1'); + }); + }); +}); diff --git a/packages/api/src/modules/stores/stores.routes.ts b/packages/api/src/modules/stores/stores.routes.ts new file mode 100644 index 0000000..e0c5065 --- /dev/null +++ b/packages/api/src/modules/stores/stores.routes.ts @@ -0,0 +1,158 @@ +import fp from 'fastify-plugin'; +import { asClass, Lifetime } from 'awilix'; +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { z } from 'zod/v4'; +import { + CreateStoreSchema, + UpdateStoreSchema, + StoreQuerySchema, + StoreResponseSchema, + StoreListResponseSchema, +} from '@meshitrack/shared'; +import { StoresRepository } from './stores.repository.js'; +import { StoresService } from './stores.service.js'; + +type AnyStoreDoc = { + _id: string | { toString: () => string }; + householdId: string; + name: string; + address?: string; + location?: { lat: number; lng: number }; + url?: string; + notes?: string; + tags: string[]; + isActive: boolean; + createdBy: string; + createdAt: string | Date | { toISOString: () => string }; + updatedAt: string | Date | { toISOString: () => string }; +}; + +function toIso(v: string | Date | { toISOString: () => string }): string { + if (typeof v === 'string') return v; + return v.toISOString(); +} + +function toStoreResponse(rawDoc: unknown) { + const doc = rawDoc as AnyStoreDoc; + return { + _id: typeof doc._id === 'string' ? doc._id : doc._id.toString(), + householdId: doc.householdId, + name: doc.name, + ...(doc.address != null ? { address: doc.address } : {}), + ...(doc.location != null ? { location: doc.location } : {}), + ...(doc.url != null ? { url: doc.url } : {}), + ...(doc.notes != null ? { notes: doc.notes } : {}), + tags: doc.tags, + isActive: doc.isActive, + createdBy: doc.createdBy, + createdAt: toIso(doc.createdAt), + updatedAt: toIso(doc.updatedAt), + }; +} + +declare module '@fastify/awilix' { + interface Cradle { + storesRepository: StoresRepository; + storesService: StoresService; + } +} + +export default fp( + async (fastify) => { + fastify.diContainer.register({ + storesRepository: asClass(StoresRepository, { lifetime: Lifetime.SINGLETON }), + storesService: asClass(StoresService, { lifetime: Lifetime.SINGLETON }), + }); + + const app = fastify.withTypeProvider(); + const householdParams = z.object({ householdId: z.string() }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/stores', + schema: { + params: householdParams, + querystring: StoreQuerySchema, + response: { 200: StoreListResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('storesService'); + const result = await service.list(request.params.householdId, request.query); + return reply.send({ + data: result.data.map(toStoreResponse), + pagination: result.pagination, + }); + }, + }); + + app.route({ + method: 'GET', + url: '/api/v1/households/:householdId/stores/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: StoreResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('storesService'); + const store = await service.getById(request.params.id, request.params.householdId); + return reply.send(toStoreResponse(store)); + }, + }); + + app.route({ + method: 'POST', + url: '/api/v1/households/:householdId/stores', + schema: { + params: householdParams, + body: CreateStoreSchema, + response: { 201: StoreResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('storesService'); + const store = await service.create( + request.body, + request.params.householdId, + request.user.keycloakId, + ); + return reply.status(201).send(toStoreResponse(store)); + }, + }); + + app.route({ + method: 'PATCH', + url: '/api/v1/households/:householdId/stores/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + body: UpdateStoreSchema, + response: { 200: StoreResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('storesService'); + const store = await service.update( + request.params.id, + request.params.householdId, + request.body, + ); + return reply.send(toStoreResponse(store)); + }, + }); + + app.route({ + method: 'DELETE', + url: '/api/v1/households/:householdId/stores/:id', + schema: { + params: householdParams.extend({ id: z.string() }), + response: { 200: StoreResponseSchema }, + }, + handler: async (request, reply) => { + const service = fastify.diContainer.resolve('storesService'); + const store = await service.deactivate(request.params.id, request.params.householdId); + return reply.send(toStoreResponse(store)); + }, + }); + }, + { + name: 'stores-routes', + dependencies: ['auth-plugin'], + }, +); diff --git a/packages/api/src/modules/stores/stores.service.test.ts b/packages/api/src/modules/stores/stores.service.test.ts new file mode 100644 index 0000000..f68388a --- /dev/null +++ b/packages/api/src/modules/stores/stores.service.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StoresService } from './stores.service.js'; + +describe(StoresService.name, () => { + const mockRepo = { + findByHousehold: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + deactivate: vi.fn(), + }; + + let service: StoresService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new StoresService({ storesRepository: mockRepo as never }); + }); + + describe('list', () => { + it('delegates to repository', async () => { + const result = { data: [], pagination: { cursor: null, hasMore: false } }; + mockRepo.findByHousehold.mockResolvedValue(result); + + const response = await service.list('hh1', { limit: 20 }); + + expect(response).toEqual(result); + expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 }); + }); + }); + + describe('getById', () => { + it('returns store when found', async () => { + const store = { _id: 'st-1', name: 'Walgreens' }; + mockRepo.findById.mockResolvedValue(store); + + expect(await service.getById('st-1', 'hh1')).toEqual(store); + }); + + it('throws NotFoundError when not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + await expect(service.getById('missing', 'hh1')).rejects.toThrow('Store not found'); + }); + }); + + describe('create', () => { + it('delegates to repository', async () => { + const store = { _id: 'st-1', name: 'CVS' }; + mockRepo.create.mockResolvedValue(store); + + const result = await service.create({ name: 'CVS', tags: [], isActive: true } as never, 'hh1', 'user-1'); + + expect(result).toEqual(store); + expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1'); + }); + }); + + describe('update', () => { + it('updates and returns store', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'st-1' }); + const updated = { _id: 'st-1', name: 'CVS' }; + mockRepo.update.mockResolvedValue(updated); + + const result = await service.update('st-1', 'hh1', { name: 'CVS' }); + + expect(result).toEqual(updated); + }); + + it('throws NotFoundError on initial lookup failure', async () => { + mockRepo.findById.mockResolvedValue(null); + + await expect(service.update('missing', 'hh1', {})).rejects.toThrow('Store not found'); + }); + + it('throws NotFoundError when update returns null', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'st-1' }); + mockRepo.update.mockResolvedValue(null); + + await expect(service.update('st-1', 'hh1', {})).rejects.toThrow('Store not found'); + }); + }); + + describe('deactivate', () => { + it('deactivates and returns store', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'st-1' }); + const deactivated = { _id: 'st-1', isActive: false }; + mockRepo.deactivate.mockResolvedValue(deactivated); + + const result = await service.deactivate('st-1', 'hh1'); + + expect(result).toEqual(deactivated); + expect(mockRepo.deactivate).toHaveBeenCalledWith('st-1', 'hh1'); + }); + + it('throws NotFoundError when store not found', async () => { + mockRepo.findById.mockResolvedValue(null); + + await expect(service.deactivate('missing', 'hh1')).rejects.toThrow('Store not found'); + }); + + it('throws NotFoundError when deactivate returns null', async () => { + mockRepo.findById.mockResolvedValue({ _id: 'st-1' }); + mockRepo.deactivate.mockResolvedValue(null); + + await expect(service.deactivate('st-1', 'hh1')).rejects.toThrow('Store not found'); + }); + }); +}); diff --git a/packages/api/src/modules/stores/stores.service.ts b/packages/api/src/modules/stores/stores.service.ts new file mode 100644 index 0000000..9bf42b3 --- /dev/null +++ b/packages/api/src/modules/stores/stores.service.ts @@ -0,0 +1,43 @@ +import type { StoresRepository } from './stores.repository.js'; +import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared'; +import { NotFoundError } from '../../common/errors.js'; + +interface Deps { + storesRepository: StoresRepository; +} + +export class StoresService { + private readonly storesRepository: StoresRepository; + + public constructor({ storesRepository }: Deps) { + this.storesRepository = storesRepository; + } + + public async list(householdId: string, query: StoreQueryInput) { + return this.storesRepository.findByHousehold(householdId, query); + } + + public async getById(id: string, householdId: string) { + const store = await this.storesRepository.findById(id, householdId); + if (!store) throw new NotFoundError('Store not found'); + return store; + } + + public async create(data: CreateStoreInput, householdId: string, userId: string) { + return this.storesRepository.create(data, householdId, userId); + } + + public async update(id: string, householdId: string, data: UpdateStoreInput) { + await this.getById(id, householdId); + const updated = await this.storesRepository.update(id, householdId, data); + if (!updated) throw new NotFoundError('Store not found'); + return updated; + } + + public async deactivate(id: string, householdId: string) { + await this.getById(id, householdId); + const updated = await this.storesRepository.deactivate(id, householdId); + if (!updated) throw new NotFoundError('Store not found'); + return updated; + } +} diff --git a/packages/api/src/schemas/medicine-price.schema.ts b/packages/api/src/schemas/medicine-price.schema.ts new file mode 100644 index 0000000..c792747 --- /dev/null +++ b/packages/api/src/schemas/medicine-price.schema.ts @@ -0,0 +1,32 @@ +import mongoose from 'mongoose'; + +const medicinePriceSchema = new mongoose.Schema( + { + householdId: { type: String, required: true }, + medicineProductId: { type: String, required: true }, + medicineProductBrand: { type: String, required: true }, + medicineId: { type: String, required: true }, + medicineName: { type: String, required: true }, + storeId: { type: String, required: true }, + storeName: { type: String, required: true }, + price: { type: Number, required: true }, + currency: { type: String, required: true }, + quantity: { type: Number, required: true }, + unit: { type: String, required: true }, + pricePerUnit: { type: Number, required: true }, + date: { type: Date, required: true }, + isInsurancePrice: { type: Boolean, required: true, default: false }, + notes: { type: String }, + createdBy: { type: String, required: true }, + }, + { timestamps: { createdAt: true, updatedAt: false } }, +); + +medicinePriceSchema.index({ householdId: 1, medicineProductId: 1, storeId: 1, date: -1 }); +medicinePriceSchema.index({ householdId: 1, medicineId: 1, date: -1 }); +medicinePriceSchema.index({ householdId: 1, storeId: 1, date: -1 }); + +export const MedicinePriceModel = mongoose.model('MedicinePrice', medicinePriceSchema); +export type MedicinePriceDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; +}; diff --git a/packages/api/src/schemas/purchase.schema.ts b/packages/api/src/schemas/purchase.schema.ts new file mode 100644 index 0000000..0c9438e --- /dev/null +++ b/packages/api/src/schemas/purchase.schema.ts @@ -0,0 +1,67 @@ +import mongoose, { type Document } from 'mongoose'; + +const { Schema, model } = mongoose; + +const purchaseItemSchema = new Schema( + { + medicineProductId: { type: String }, + medicineId: { type: String }, + foodProductId: { type: String }, + name: { type: String, required: true }, + quantity: { type: Number, required: true }, + unit: { type: String, required: true }, + actualPrice: { type: Number }, + currency: { type: String }, + priceRecordId: { type: String }, + addedToCabinet: { type: Boolean, default: false }, + }, + { _id: false }, +); + +const purchaseSchema = new Schema( + { + householdId: { type: String, required: true }, + storeId: { type: String, required: true }, + storeName: { type: String, required: true }, + status: { type: String, enum: ['ordered', 'in_cabinet'], required: true }, + items: { type: [purchaseItemSchema], required: true }, + notes: { type: String }, + purchasedAt: { type: Date, required: true }, + receivedAt: { type: Date }, + createdBy: { type: String, required: true }, + isDeleted: { type: Boolean, default: false }, + }, + { timestamps: true }, +); + +purchaseSchema.index({ householdId: 1, status: 1, purchasedAt: -1 }); +purchaseSchema.index({ householdId: 1, storeId: 1 }); +purchaseSchema.index({ householdId: 1, 'items.medicineProductId': 1 }); + +export type PurchaseDocument = Document & { + householdId: string; + storeId: string; + storeName: string; + status: string; + items: Array<{ + medicineProductId?: string; + medicineId?: string; + foodProductId?: string; + name: string; + quantity: number; + unit: string; + actualPrice?: number; + currency?: string; + priceRecordId?: string; + addedToCabinet: boolean; + }>; + notes?: string; + purchasedAt: Date; + receivedAt?: Date; + createdBy: string; + isDeleted: boolean; + createdAt: Date; + updatedAt: Date; +}; + +export const PurchaseModel = model('Purchase', purchaseSchema); diff --git a/packages/api/src/schemas/refill-list.schema.ts b/packages/api/src/schemas/refill-list.schema.ts new file mode 100644 index 0000000..b042120 --- /dev/null +++ b/packages/api/src/schemas/refill-list.schema.ts @@ -0,0 +1,46 @@ +import mongoose from 'mongoose'; +import { DosageUnit } from '@meshitrack/shared'; +import { RefillListStatus } from '@meshitrack/shared'; + +const refillListItemSchema = new mongoose.Schema( + { + medicineId: { type: String, required: true }, + medicineName: { type: String, required: true }, + quantity: { type: Number, required: true, min: 0 }, + unit: { type: String, enum: Object.values(DosageUnit), required: true }, + estimatedPrice: { type: Number, min: 0 }, + actualPrice: { type: Number, min: 0 }, + checked: { type: Boolean, required: true, default: false }, + checkedAt: { type: Date }, + addedToCabinet: { type: Boolean, required: true, default: false }, + storeId: { type: String }, + notes: { type: String }, + }, + { _id: true }, +); + +const refillListSchema = new mongoose.Schema( + { + householdId: { type: String, required: true }, + name: { type: String, required: true }, + items: { type: [refillListItemSchema], required: true, default: [] }, + status: { + type: String, + enum: Object.values(RefillListStatus), + required: true, + default: RefillListStatus.ACTIVE, + }, + preferredStoreId: { type: String }, + totalEstimatedCost: { type: Number }, + createdBy: { type: String, required: true }, + }, + { timestamps: true }, +); + +refillListSchema.index({ householdId: 1, status: 1 }); +refillListSchema.index({ householdId: 1, createdAt: -1 }); + +export const RefillListModel = mongoose.model('RefillList', refillListSchema); +export type RefillListDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; +}; diff --git a/packages/api/src/schemas/store.schema.ts b/packages/api/src/schemas/store.schema.ts new file mode 100644 index 0000000..1a744fd --- /dev/null +++ b/packages/api/src/schemas/store.schema.ts @@ -0,0 +1,32 @@ +import mongoose from 'mongoose'; + +const locationSchema = new mongoose.Schema( + { + lat: { type: Number, required: true }, + lng: { type: Number, required: true }, + }, + { _id: false }, +); + +const storeSchema = new mongoose.Schema( + { + householdId: { type: String, required: true }, + name: { type: String, required: true }, + address: { type: String }, + location: { type: locationSchema }, + url: { type: String }, + notes: { type: String }, + tags: { type: [String], default: [] }, + isActive: { type: Boolean, required: true, default: true }, + createdBy: { type: String, required: true }, + }, + { timestamps: true }, +); + +storeSchema.index({ householdId: 1, name: 1 }); +storeSchema.index({ householdId: 1, tags: 1 }); + +export const StoreModel = mongoose.model('Store', storeSchema); +export type StoreDocument = mongoose.InferSchemaType & { + _id: mongoose.Types.ObjectId; +}; diff --git a/packages/api/src/scripts/migrate-dosage-units.ts b/packages/api/src/scripts/migrate-dosage-units.ts new file mode 100644 index 0000000..c7118d1 --- /dev/null +++ b/packages/api/src/scripts/migrate-dosage-units.ts @@ -0,0 +1,107 @@ +/** + * Migration: normalize legacy dosage unit values to current DosageUnit enum. + * + * Known renames: + * pill → tablet + * pills → tablet + * + * Collections updated: + * medicineproducts packageUnit + * cabinetitems unit + * regimens medications[].dosageUnit + * refillists items[].unit + */ +import mongoose from 'mongoose'; + +const MONGODB_URI = + process.env.SEED_MONGODB_URI || + process.env.MONGODB_URI || + 'mongodb://meshitrack:devpassword@localhost:27017/meshitrack?authSource=admin&replicaSet=rs0'; + +const UNIT_MAP: Record = { + pill: 'tablet', + pills: 'tablet', +}; + +async function migrate() { + console.log('Connecting...'); + await mongoose.connect(MONGODB_URI); + const db = mongoose.connection.db!; + + let total = 0; + + // --- medicineproducts.packageUnit --- + for (const [old, next] of Object.entries(UNIT_MAP)) { + const result = await db + .collection('medicineproducts') + .updateMany({ packageUnit: old }, { $set: { packageUnit: next } }); + if (result.modifiedCount > 0) { + console.log(`medicineproducts.packageUnit: ${old} → ${next} (${result.modifiedCount})`); + total += result.modifiedCount; + } + } + + // --- cabinetitems.unit --- + for (const [old, next] of Object.entries(UNIT_MAP)) { + const result = await db + .collection('cabinetitems') + .updateMany({ unit: old }, { $set: { unit: next } }); + if (result.modifiedCount > 0) { + console.log(`cabinetitems.unit: ${old} → ${next} (${result.modifiedCount})`); + total += result.modifiedCount; + } + } + + // --- regimens: medications[].dosageUnit --- + // MongoDB cannot target a specific array element value with updateMany + $set on nested + // fields matching a condition, so we load and rewrite affected documents. + const regimens = await db.collection('regimens').find({}).toArray(); + for (const regimen of regimens) { + const medications: Array> = regimen.medications ?? []; + let dirty = false; + for (const med of medications) { + const mapped = UNIT_MAP[med.dosageUnit as string]; + if (mapped) { + med.dosageUnit = mapped; + dirty = true; + } + } + if (dirty) { + await db + .collection('regimens') + .updateOne({ _id: regimen._id }, { $set: { medications } }); + console.log(`regimens: updated medications in regimen ${regimen._id}`); + total++; + } + } + + // --- refillists: items[].unit --- + const refillLists = await db.collection('refillists').find({}).toArray(); + for (const list of refillLists) { + const items: Array> = list.items ?? []; + let dirty = false; + for (const item of items) { + const mapped = UNIT_MAP[item.unit as string]; + if (mapped) { + item.unit = mapped; + dirty = true; + } + } + if (dirty) { + await db + .collection('refillists') + .updateOne({ _id: list._id }, { $set: { items } }); + console.log(`refillists: updated items in list ${list._id}`); + total++; + } + } + + console.log(''); + console.log(`Migration complete. ${total} document(s) updated.`); + await mongoose.disconnect(); +} + +migrate().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/packages/shared/src/enums/index.ts b/packages/shared/src/enums/index.ts index e93a5c4..e914b88 100644 --- a/packages/shared/src/enums/index.ts +++ b/packages/shared/src/enums/index.ts @@ -3,3 +3,5 @@ export * from './medicine.enums.js'; export * from './cabinet.enums.js'; export * from './cabinet-event.enums.js'; export * from './regimen.enums.js'; +export * from './refill.enums.js'; +export * from './purchase.enums.js'; diff --git a/packages/shared/src/enums/purchase.enums.test.ts b/packages/shared/src/enums/purchase.enums.test.ts new file mode 100644 index 0000000..9f7b682 --- /dev/null +++ b/packages/shared/src/enums/purchase.enums.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { PurchaseStatus } from './purchase.enums.js'; + +describe(PurchaseStatus.name, () => { + it('has exactly 2 values', () => { + expect(Object.values(PurchaseStatus)).toHaveLength(2); + }); + + it.each([ + ['ORDERED', 'ordered'], + ['IN_CABINET', 'in_cabinet'], + ])('%s = %s', (key, value) => { + expect(PurchaseStatus[key as keyof typeof PurchaseStatus]).toBe(value); + }); +}); diff --git a/packages/shared/src/enums/purchase.enums.ts b/packages/shared/src/enums/purchase.enums.ts new file mode 100644 index 0000000..2fbf1c2 --- /dev/null +++ b/packages/shared/src/enums/purchase.enums.ts @@ -0,0 +1,4 @@ +export enum PurchaseStatus { + ORDERED = 'ordered', + IN_CABINET = 'in_cabinet', +} diff --git a/packages/shared/src/enums/refill.enums.test.ts b/packages/shared/src/enums/refill.enums.test.ts new file mode 100644 index 0000000..b5df62c --- /dev/null +++ b/packages/shared/src/enums/refill.enums.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { RefillListStatus } from './refill.enums.js'; + +describe(RefillListStatus.name, () => { + it('has exactly 4 values', () => { + expect(Object.values(RefillListStatus)).toHaveLength(4); + }); + + it.each([ + ['ACTIVE', 'active'], + ['SHOPPING', 'shopping'], + ['COMPLETED', 'completed'], + ['ARCHIVED', 'archived'], + ])('%s = %s', (key, value) => { + expect(RefillListStatus[key as keyof typeof RefillListStatus]).toBe(value); + }); +}); diff --git a/packages/shared/src/enums/refill.enums.ts b/packages/shared/src/enums/refill.enums.ts new file mode 100644 index 0000000..5177d37 --- /dev/null +++ b/packages/shared/src/enums/refill.enums.ts @@ -0,0 +1,6 @@ +export enum RefillListStatus { + ACTIVE = 'active', + SHOPPING = 'shopping', + COMPLETED = 'completed', + ARCHIVED = 'archived', +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0fb3cee..adb89ae 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -7,3 +7,7 @@ export * from './cabinet-event.js'; export * from './regimen.js'; export * from './organizer-fill.js'; export * from './burn-rate.js'; +export * from './store.js'; +export * from './medicine-price.js'; +export * from './refill.js'; +export * from './purchase.js'; diff --git a/packages/shared/src/types/medicine-price.ts b/packages/shared/src/types/medicine-price.ts new file mode 100644 index 0000000..846c1ef --- /dev/null +++ b/packages/shared/src/types/medicine-price.ts @@ -0,0 +1,56 @@ +import type { DosageUnit } from '../enums/medicine.enums.js'; + +export interface MedicinePriceRecord { + id: string; + householdId: string; + medicineProductId: string; + medicineProductBrand: string; + medicineId: string; + medicineName: string; + storeId: string; + storeName: string; + price: number; + currency: string; + quantity: number; + unit: DosageUnit; + pricePerUnit: number; + date: Date; + isInsurancePrice: boolean; + notes?: string; + createdBy: string; + createdAt: Date; +} + +export interface StoreComparison { + storeId: string; + storeName: string; + latestPrice: number; + latestPricePerUnit: number; + currency: string; + date: Date; + isInsurancePrice: boolean; +} + +export interface MedicineSpendingAnalytics { + spendingOverTime: { period: string; total: number }[]; + topBySpending: { + medicineId: string; + medicineName: string; + totalSpent: number; + avgPricePerUnit: number; + }[]; + spendingByStore: { + storeId: string; + storeName: string; + totalSpent: number; + purchaseCount: number; + }[]; + priceAlerts: { + medicineId: string; + medicineName: string; + storeName: string; + previousPrice: number; + currentPrice: number; + changePercent: number; + }[]; +} diff --git a/packages/shared/src/types/purchase.ts b/packages/shared/src/types/purchase.ts new file mode 100644 index 0000000..093de83 --- /dev/null +++ b/packages/shared/src/types/purchase.ts @@ -0,0 +1,30 @@ +import type { PurchaseStatus } from '../enums/purchase.enums.js'; + +export interface PurchaseItem { + id: string; + medicineProductId?: string; + medicineId?: string; + foodProductId?: string; + name: string; + quantity: number; + unit: string; + actualPrice?: number; + currency?: string; + priceRecordId?: string; + addedToCabinet: boolean; +} + +export interface Purchase { + id: string; + householdId: string; + storeId: string; + storeName: string; + status: PurchaseStatus; + items: PurchaseItem[]; + notes?: string; + purchasedAt: Date; + receivedAt?: Date; + createdBy: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/shared/src/types/refill.ts b/packages/shared/src/types/refill.ts new file mode 100644 index 0000000..59d5df2 --- /dev/null +++ b/packages/shared/src/types/refill.ts @@ -0,0 +1,57 @@ +import type { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js'; +import type { RefillListStatus } from '../enums/refill.enums.js'; + +export interface RefillAlert { + medicineId: string; + medicineName: string; + medicineStrength: number; + medicineStrengthUnit: StrengthUnit; + daysUntilEmpty: number; + dailyConsumption: number; + currentStock: number; + pendingOrderStock: number; + daysUntilEmptyWithOrders: number | null; + suggestedQuantity: number; + lastKnownPrice?: { + price: number; + pricePerUnit: number; + storeName: string; + storeId: string; + date: Date; + }; + cheapestOption?: { + price: number; + pricePerUnit: number; + storeName: string; + storeId: string; + date: Date; + }; +} + +export interface RefillListItem { + id: string; + medicineId: string; + medicineName: string; + quantity: number; + unit: DosageUnit; + estimatedPrice?: number; + actualPrice?: number; + checked: boolean; + checkedAt?: Date; + addedToCabinet: boolean; + storeId?: string; + notes?: string; +} + +export interface RefillList { + id: string; + householdId: string; + name: string; + items: RefillListItem[]; + status: RefillListStatus; + preferredStoreId?: string; + totalEstimatedCost?: number; + createdBy: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/shared/src/types/store.ts b/packages/shared/src/types/store.ts new file mode 100644 index 0000000..5061cdc --- /dev/null +++ b/packages/shared/src/types/store.ts @@ -0,0 +1,17 @@ +export interface Store { + id: string; + householdId: string; + name: string; + address?: string; + location?: { + lat: number; + lng: number; + }; + url?: string; + notes?: string; + tags: string[]; + isActive: boolean; + createdBy: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/shared/src/validation/index.ts b/packages/shared/src/validation/index.ts index f588e55..2759513 100644 --- a/packages/shared/src/validation/index.ts +++ b/packages/shared/src/validation/index.ts @@ -5,3 +5,7 @@ export * from './cabinet.schemas.js'; export * from './cabinet-event.schemas.js'; export * from './regimen.schemas.js'; export * from './organizer.schemas.js'; +export * from './store.schemas.js'; +export * from './medicine-price.schemas.js'; +export * from './refill.schemas.js'; +export * from './purchase.schemas.js'; diff --git a/packages/shared/src/validation/medicine-price.schemas.test.ts b/packages/shared/src/validation/medicine-price.schemas.test.ts new file mode 100644 index 0000000..aa19045 --- /dev/null +++ b/packages/shared/src/validation/medicine-price.schemas.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { DosageUnit } from '../enums/medicine.enums.js'; +import { + CreateMedicinePriceRecordSchema, + MedicinePriceHistoryQuerySchema, + MedicinePriceAnalyticsQuerySchema, +} from './medicine-price.schemas.js'; + +const validInput = { + medicineProductId: 'prod-1', + medicineId: 'med-1', + storeId: 'store-1', + price: 9.99, + currency: 'USD', + quantity: 30, + unit: DosageUnit.TABLET, +}; + +describe('CreateMedicinePriceRecordSchema', () => { + it('accepts valid input', () => { + expect(CreateMedicinePriceRecordSchema.safeParse(validInput).success).toBe(true); + }); + + it('defaults isInsurancePrice to false', () => { + const result = CreateMedicinePriceRecordSchema.parse(validInput); + expect(result.isInsurancePrice).toBe(false); + }); + + it('accepts optional date as ISO datetime', () => { + const result = CreateMedicinePriceRecordSchema.safeParse({ + ...validInput, + date: '2026-01-15T00:00:00.000Z', + }); + expect(result.success).toBe(true); + }); + + it('rejects non-positive price', () => { + expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(false); + }); + + it('rejects non-positive quantity', () => { + expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(false); + }); + + it('rejects invalid unit', () => { + expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success).toBe(false); + }); +}); + +describe('MedicinePriceHistoryQuerySchema', () => { + it('applies default limit', () => { + const result = MedicinePriceHistoryQuerySchema.parse({}); + expect(result.limit).toBe(20); + }); + + it('accepts date range filters', () => { + const result = MedicinePriceHistoryQuerySchema.parse({ + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2026-03-31T00:00:00.000Z', + }); + expect(result.startDate).toBeTruthy(); + expect(result.endDate).toBeTruthy(); + }); +}); + +describe('MedicinePriceAnalyticsQuerySchema', () => { + it('defaults to month period', () => { + const result = MedicinePriceAnalyticsQuerySchema.parse({}); + expect(result.period).toBe('month'); + }); + + it.each(['month', 'quarter', 'year'])('accepts period %s', (period) => { + expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period }).success).toBe(true); + }); + + it('rejects invalid period', () => { + expect(MedicinePriceAnalyticsQuerySchema.safeParse({ period: 'week' }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/validation/medicine-price.schemas.ts b/packages/shared/src/validation/medicine-price.schemas.ts new file mode 100644 index 0000000..632ca4f --- /dev/null +++ b/packages/shared/src/validation/medicine-price.schemas.ts @@ -0,0 +1,98 @@ +import { z } from 'zod/v4'; +import { DosageUnit } from '../enums/medicine.enums.js'; + +export const CreateMedicinePriceRecordSchema = z.object({ + medicineProductId: z.string().min(1), + medicineId: z.string().min(1), + storeId: z.string().min(1), + price: z.number().positive(), + currency: z.string().min(1).max(10), + quantity: z.number().positive(), + unit: z.nativeEnum(DosageUnit), + date: z.iso.datetime().optional(), + isInsurancePrice: z.boolean().default(false), + notes: z.string().max(1000).trim().optional(), +}); + +export const MedicinePriceHistoryQuerySchema = z.object({ + storeId: z.string().optional(), + startDate: z.iso.datetime().optional(), + endDate: z.iso.datetime().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const MedicinePriceAnalyticsQuerySchema = z.object({ + period: z.enum(['month', 'quarter', 'year']).default('month'), +}); + +export const MedicinePriceRecordResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + medicineProductId: z.string(), + medicineProductBrand: z.string(), + medicineId: z.string(), + medicineName: z.string(), + storeId: z.string(), + storeName: z.string(), + price: z.number(), + currency: z.string(), + quantity: z.number(), + unit: z.string(), + pricePerUnit: z.number(), + date: z.string(), + isInsurancePrice: z.boolean(), + notes: z.string().optional(), + createdBy: z.string(), + createdAt: z.string(), +}); + +export const MedicinePriceHistoryResponseSchema = z.object({ + data: z.array(MedicinePriceRecordResponseSchema), + pagination: z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), +}); + +export const StoreComparisonItemSchema = z.object({ + storeId: z.string(), + storeName: z.string(), + latestPrice: z.number(), + latestPricePerUnit: z.number(), + currency: z.string(), + date: z.string(), + isInsurancePrice: z.boolean(), +}); + +export const StoreComparisonResponseSchema = z.object({ + data: z.array(StoreComparisonItemSchema), +}); + +export const MedicineSpendingAnalyticsResponseSchema = z.object({ + spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })), + topBySpending: z.array(z.object({ + medicineId: z.string(), + medicineName: z.string(), + totalSpent: z.number(), + avgPricePerUnit: z.number(), + })), + spendingByStore: z.array(z.object({ + storeId: z.string(), + storeName: z.string(), + totalSpent: z.number(), + purchaseCount: z.number(), + })), + priceAlerts: z.array(z.object({ + medicineId: z.string(), + medicineName: z.string(), + storeName: z.string(), + previousPrice: z.number(), + currentPrice: z.number(), + changePercent: z.number(), + })), +}); + +export type CreateMedicinePriceRecordInput = z.infer; +export type MedicinePriceHistoryQueryInput = z.infer; +export type MedicinePriceAnalyticsQueryInput = z.infer; diff --git a/packages/shared/src/validation/purchase.schemas.test.ts b/packages/shared/src/validation/purchase.schemas.test.ts new file mode 100644 index 0000000..bf97897 --- /dev/null +++ b/packages/shared/src/validation/purchase.schemas.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { + CreatePurchaseItemSchema, + CreatePurchaseSchema, + UpdatePurchaseSchema, + PurchaseQuerySchema, +} from './purchase.schemas.js'; + +describe('CreatePurchaseItemSchema', () => { + it('accepts minimal valid item', () => { + const result = CreatePurchaseItemSchema.safeParse({ name: 'Tylenol 30ct', quantity: 30, unit: 'tablet' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.addedToCabinet).toBeUndefined(); + }); + + it('accepts item with all fields', () => { + const result = CreatePurchaseItemSchema.safeParse({ + medicineProductId: 'mp-1', + name: 'Tylenol 30ct', + quantity: 30, + unit: 'tablet', + actualPrice: 9.99, + currency: 'USD', + priceRecordId: 'pr-1', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + expect(CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success).toBe(false); + }); + + it('rejects non-positive quantity', () => { + expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success).toBe(false); + }); + + it('rejects empty unit', () => { + expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(false); + }); + + it('trims name whitespace', () => { + const result = CreatePurchaseItemSchema.parse({ name: ' Tylenol ', quantity: 1, unit: 'tablet' }); + expect(result.name).toBe('Tylenol'); + }); +}); + +describe('CreatePurchaseSchema', () => { + const validItem = { name: 'Tylenol', quantity: 30, unit: 'tablet' }; + + it('defaults status to in_cabinet', () => { + const result = CreatePurchaseSchema.parse({ storeId: 'st-1', items: [validItem] }); + expect(result.status).toBe('in_cabinet'); + }); + + it('accepts ordered status', () => { + const result = CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'ordered', items: [validItem] }); + expect(result.success).toBe(true); + }); + + it('rejects empty storeId', () => { + expect(CreatePurchaseSchema.safeParse({ storeId: '', items: [validItem] }).success).toBe(false); + }); + + it('rejects empty items array', () => { + expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [] }).success).toBe(false); + }); + + it('rejects invalid status', () => { + expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] }).success).toBe(false); + }); + + it('accepts optional purchasedAt datetime', () => { + const result = CreatePurchaseSchema.safeParse({ + storeId: 'st-1', + items: [validItem], + purchasedAt: '2026-01-15T00:00:00.000Z', + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid purchasedAt', () => { + expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [validItem], purchasedAt: 'not-a-date' }).success).toBe(false); + }); +}); + +describe('UpdatePurchaseSchema', () => { + it('accepts empty object', () => { + expect(UpdatePurchaseSchema.safeParse({}).success).toBe(true); + }); + + it('accepts notes only', () => { + expect(UpdatePurchaseSchema.safeParse({ notes: 'updated' }).success).toBe(true); + }); + + it('trims notes whitespace', () => { + const result = UpdatePurchaseSchema.parse({ notes: ' hello ' }); + expect(result.notes).toBe('hello'); + }); +}); + +describe('PurchaseQuerySchema', () => { + it('defaults limit to 20', () => { + const result = PurchaseQuerySchema.parse({}); + expect(result.limit).toBe(20); + }); + + it('accepts status filter', () => { + expect(PurchaseQuerySchema.safeParse({ status: 'ordered' }).success).toBe(true); + expect(PurchaseQuerySchema.safeParse({ status: 'in_cabinet' }).success).toBe(true); + }); + + it('rejects invalid status', () => { + expect(PurchaseQuerySchema.safeParse({ status: 'unknown' }).success).toBe(false); + }); + + it('coerces string limit', () => { + const result = PurchaseQuerySchema.parse({ limit: '50' }); + expect(result.limit).toBe(50); + }); +}); diff --git a/packages/shared/src/validation/purchase.schemas.ts b/packages/shared/src/validation/purchase.schemas.ts new file mode 100644 index 0000000..8706501 --- /dev/null +++ b/packages/shared/src/validation/purchase.schemas.ts @@ -0,0 +1,74 @@ +import { z } from 'zod/v4'; + +export const CreatePurchaseItemSchema = z.object({ + medicineProductId: z.string().optional(), + name: z.string().min(1).max(200).trim(), + quantity: z.number().positive(), + unit: z.string().min(1), + actualPrice: z.number().positive().optional(), + currency: z.string().min(1).max(10).optional(), + priceRecordId: z.string().optional(), +}); + +export const CreatePurchaseSchema = z.object({ + storeId: z.string().min(1), + status: z.enum(['ordered', 'in_cabinet']).default('in_cabinet'), + items: z.array(CreatePurchaseItemSchema).min(1), + notes: z.string().max(1000).trim().optional(), + purchasedAt: z.iso.datetime().optional(), +}); + +export const UpdatePurchaseSchema = z.object({ + notes: z.string().max(1000).trim().optional(), + items: z.array(CreatePurchaseItemSchema).min(1).optional(), +}); + +export const PurchaseItemResponseSchema = z.object({ + _id: z.string(), + medicineProductId: z.string().optional(), + medicineId: z.string().optional(), + foodProductId: z.string().optional(), + name: z.string(), + quantity: z.number(), + unit: z.string(), + actualPrice: z.number().optional(), + currency: z.string().optional(), + priceRecordId: z.string().optional(), + addedToCabinet: z.boolean(), +}); + +export const PurchaseResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + storeId: z.string(), + storeName: z.string(), + status: z.enum(['ordered', 'in_cabinet']), + items: z.array(PurchaseItemResponseSchema), + notes: z.string().optional(), + purchasedAt: z.string(), + receivedAt: z.string().optional(), + createdBy: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const PurchaseListResponseSchema = z.object({ + data: z.array(PurchaseResponseSchema), + pagination: z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean(), + total: z.number().optional(), + }), +}); + +export const PurchaseQuerySchema = z.object({ + status: z.enum(['ordered', 'in_cabinet']).optional(), + storeId: z.string().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export type CreatePurchaseItemInput = z.infer; +export type CreatePurchaseInput = z.infer; +export type UpdatePurchaseInput = z.infer; +export type PurchaseQueryInput = z.infer; diff --git a/packages/shared/src/validation/refill.schemas.test.ts b/packages/shared/src/validation/refill.schemas.test.ts new file mode 100644 index 0000000..684330e --- /dev/null +++ b/packages/shared/src/validation/refill.schemas.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { DosageUnit } from '../enums/medicine.enums.js'; +import { RefillListStatus } from '../enums/refill.enums.js'; +import { + CreateRefillListSchema, + UpdateRefillListSchema, + UpdateRefillListItemSchema, + RefillListQuerySchema, + RefillAlertQuerySchema, +} from './refill.schemas.js'; + +describe('CreateRefillListSchema', () => { + it('accepts minimal valid input', () => { + const result = CreateRefillListSchema.parse({ name: 'Weekly Refills' }); + expect(result.fromAlerts).toBe(false); + expect(result.thresholdDays).toBe(7); + }); + + it('accepts fromAlerts with threshold', () => { + const result = CreateRefillListSchema.parse({ + name: 'Auto List', + fromAlerts: true, + thresholdDays: 14, + }); + expect(result.fromAlerts).toBe(true); + expect(result.thresholdDays).toBe(14); + }); + + it('accepts items array', () => { + const result = CreateRefillListSchema.safeParse({ + name: 'Manual List', + items: [ + { + medicineId: 'med-1', + medicineName: 'Metformin', + quantity: 30, + unit: DosageUnit.TABLET, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + expect(CreateRefillListSchema.safeParse({ name: '' }).success).toBe(false); + }); + + it('rejects item with non-positive quantity', () => { + expect( + CreateRefillListSchema.safeParse({ + name: 'List', + items: [{ medicineId: 'med-1', medicineName: 'X', quantity: 0, unit: DosageUnit.TABLET }], + }).success, + ).toBe(false); + }); + + it('trims name whitespace', () => { + const result = CreateRefillListSchema.parse({ name: ' My List ' }); + expect(result.name).toBe('My List'); + }); +}); + +describe('UpdateRefillListSchema', () => { + it('accepts empty object', () => { + expect(UpdateRefillListSchema.safeParse({}).success).toBe(true); + }); + + it('accepts status update', () => { + const result = UpdateRefillListSchema.parse({ status: RefillListStatus.SHOPPING }); + expect(result.status).toBe(RefillListStatus.SHOPPING); + }); + + it('rejects invalid status', () => { + expect(UpdateRefillListSchema.safeParse({ status: 'invalid' }).success).toBe(false); + }); +}); + +describe('UpdateRefillListItemSchema', () => { + it('accepts checked=true', () => { + expect(UpdateRefillListItemSchema.safeParse({ checked: true }).success).toBe(true); + }); + + it('accepts actualPrice', () => { + const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.50 }); + expect(result.actualPrice).toBe(12.50); + }); + + it('rejects negative actualPrice', () => { + expect(UpdateRefillListItemSchema.safeParse({ actualPrice: -1 }).success).toBe(false); + }); +}); + +describe('RefillListQuerySchema', () => { + it('applies default limit', () => { + expect(RefillListQuerySchema.parse({}).limit).toBe(20); + }); + + it('accepts status filter', () => { + const result = RefillListQuerySchema.parse({ status: RefillListStatus.ACTIVE }); + expect(result.status).toBe(RefillListStatus.ACTIVE); + }); +}); + +describe('RefillAlertQuerySchema', () => { + it('defaults thresholdDays to 7', () => { + expect(RefillAlertQuerySchema.parse({}).thresholdDays).toBe(7); + }); + + it('coerces thresholdDays from string', () => { + expect(RefillAlertQuerySchema.parse({ thresholdDays: '14' }).thresholdDays).toBe(14); + }); +}); diff --git a/packages/shared/src/validation/refill.schemas.ts b/packages/shared/src/validation/refill.schemas.ts new file mode 100644 index 0000000..32423e4 --- /dev/null +++ b/packages/shared/src/validation/refill.schemas.ts @@ -0,0 +1,115 @@ +import { z } from 'zod/v4'; +import { DosageUnit, StrengthUnit } from '../enums/medicine.enums.js'; +import { RefillListStatus } from '../enums/refill.enums.js'; + +export const CreateRefillListItemSchema = z.object({ + medicineId: z.string().min(1), + medicineName: z.string().min(1), + quantity: z.number().positive(), + unit: z.nativeEnum(DosageUnit), + estimatedPrice: z.number().nonnegative().optional(), + storeId: z.string().optional(), + notes: z.string().max(500).trim().optional(), +}); + +export const CreateRefillListSchema = z.object({ + name: z.string().min(1).max(200).trim(), + preferredStoreId: z.string().optional(), + fromAlerts: z.boolean().default(false), + thresholdDays: z.number().int().min(1).max(365).default(7), + items: z.array(CreateRefillListItemSchema).optional(), +}); + +export const UpdateRefillListSchema = z.object({ + name: z.string().min(1).max(200).trim().optional(), + status: z.nativeEnum(RefillListStatus).optional(), + preferredStoreId: z.string().optional(), +}); + +export const UpdateRefillListItemSchema = z.object({ + checked: z.boolean().optional(), + actualPrice: z.number().nonnegative().optional(), + storeId: z.string().optional(), + notes: z.string().max(500).trim().optional(), +}); + +export const RefillListQuerySchema = z.object({ + status: z.nativeEnum(RefillListStatus).optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const RefillAlertQuerySchema = z.object({ + thresholdDays: z.coerce.number().int().min(1).max(365).default(7), + userId: z.string().optional(), +}); + +const PriceOptionSchema = z.object({ + price: z.number(), + pricePerUnit: z.number(), + storeName: z.string(), + storeId: z.string(), + date: z.string(), +}); + +export const RefillAlertResponseSchema = z.object({ + medicineId: z.string(), + medicineName: z.string(), + medicineStrength: z.number(), + medicineStrengthUnit: z.nativeEnum(StrengthUnit), + daysUntilEmpty: z.number(), + dailyConsumption: z.number(), + currentStock: z.number(), + pendingOrderStock: z.number().optional(), + daysUntilEmptyWithOrders: z.number().nullable().optional(), + suggestedQuantity: z.number(), + lastKnownPrice: PriceOptionSchema.optional(), + cheapestOption: PriceOptionSchema.optional(), +}); + +export const RefillListItemResponseSchema = z.object({ + _id: z.string(), + medicineId: z.string(), + medicineName: z.string(), + quantity: z.number(), + unit: z.string(), + estimatedPrice: z.number().optional(), + actualPrice: z.number().optional(), + checked: z.boolean(), + checkedAt: z.string().optional(), + addedToCabinet: z.boolean(), + storeId: z.string().optional(), + notes: z.string().optional(), +}); + +export const RefillListResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + name: z.string(), + items: z.array(RefillListItemResponseSchema), + status: z.nativeEnum(RefillListStatus), + preferredStoreId: z.string().optional(), + totalEstimatedCost: z.number().optional(), + createdBy: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const RefillListListResponseSchema = z.object({ + data: z.array(RefillListResponseSchema), + pagination: z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), +}); + +export const AddToCabinetResponseSchema = z.object({ + addedCount: z.number(), + priceRecordsCreated: z.number(), +}); + +export type CreateRefillListInput = z.infer; +export type UpdateRefillListInput = z.infer; +export type UpdateRefillListItemInput = z.infer; +export type RefillListQueryInput = z.infer; +export type RefillAlertQueryInput = z.infer; diff --git a/packages/shared/src/validation/store.schemas.test.ts b/packages/shared/src/validation/store.schemas.test.ts new file mode 100644 index 0000000..636d365 --- /dev/null +++ b/packages/shared/src/validation/store.schemas.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { CreateStoreSchema, UpdateStoreSchema, StoreQuerySchema } from './store.schemas.js'; + +describe('CreateStoreSchema', () => { + it('accepts minimal valid input', () => { + const result = CreateStoreSchema.safeParse({ name: 'Walgreens' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tags).toEqual([]); + expect(result.data.isActive).toBe(true); + } + }); + + it('accepts full valid input', () => { + const result = CreateStoreSchema.safeParse({ + name: 'CVS Pharmacy', + address: '123 Main St', + location: { lat: 40.7128, lng: -74.006 }, + url: 'https://cvs.com', + notes: 'Open 24h', + tags: ['pharmacy', 'retail'], + isActive: true, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty name', () => { + expect(CreateStoreSchema.safeParse({ name: '' }).success).toBe(false); + }); + + it('rejects invalid location lat', () => { + expect( + CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success, + ).toBe(false); + }); + + it('rejects invalid location lng', () => { + expect( + CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success, + ).toBe(false); + }); + + it('trims name whitespace', () => { + const result = CreateStoreSchema.parse({ name: ' Rite Aid ' }); + expect(result.name).toBe('Rite Aid'); + }); +}); + +describe('UpdateStoreSchema', () => { + it('accepts empty object', () => { + expect(UpdateStoreSchema.safeParse({}).success).toBe(true); + }); + + it('accepts partial update', () => { + const result = UpdateStoreSchema.safeParse({ isActive: false }); + expect(result.success).toBe(true); + }); + + it('accepts tags update', () => { + const result = UpdateStoreSchema.safeParse({ tags: ['grocery'] }); + expect(result.success).toBe(true); + }); +}); + +describe('StoreQuerySchema', () => { + it('applies default limit', () => { + const result = StoreQuerySchema.parse({}); + expect(result.limit).toBe(20); + }); + + it('coerces limit from string', () => { + const result = StoreQuerySchema.parse({ limit: '5' }); + expect(result.limit).toBe(5); + }); + + it('accepts tags and search filters', () => { + const result = StoreQuerySchema.parse({ tags: 'pharmacy', search: 'walgreens' }); + expect(result.tags).toBe('pharmacy'); + expect(result.search).toBe('walgreens'); + }); +}); diff --git a/packages/shared/src/validation/store.schemas.ts b/packages/shared/src/validation/store.schemas.ts new file mode 100644 index 0000000..739d4ac --- /dev/null +++ b/packages/shared/src/validation/store.schemas.ts @@ -0,0 +1,60 @@ +import { z } from 'zod/v4'; + +const locationSchema = z.object({ + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), +}); + +export const CreateStoreSchema = z.object({ + name: z.string().min(1).max(200).trim(), + address: z.string().max(500).trim().optional(), + location: locationSchema.optional(), + url: z.url().optional(), + notes: z.string().max(1000).trim().optional(), + tags: z.array(z.string().min(1).max(50).trim()).default([]), + isActive: z.boolean().default(true), +}); + +export const UpdateStoreSchema = z.object({ + name: z.string().min(1).max(200).trim().optional(), + address: z.string().max(500).trim().optional(), + location: locationSchema.optional(), + url: z.url().optional(), + notes: z.string().max(1000).trim().optional(), + tags: z.array(z.string().min(1).max(50).trim()).optional(), + isActive: z.boolean().optional(), +}); + +export const StoreQuerySchema = z.object({ + tags: z.string().optional(), + search: z.string().max(200).optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const StoreResponseSchema = z.object({ + _id: z.string(), + householdId: z.string(), + name: z.string(), + address: z.string().optional(), + location: locationSchema.optional(), + url: z.string().optional(), + notes: z.string().optional(), + tags: z.array(z.string()), + isActive: z.boolean(), + createdBy: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const StoreListResponseSchema = z.object({ + data: z.array(StoreResponseSchema), + pagination: z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean(), + }), +}); + +export type CreateStoreInput = z.infer; +export type UpdateStoreInput = z.infer; +export type StoreQueryInput = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index d4d2a00..c449092 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,7 +10,8 @@ "lint": "eslint src", "lint-fix": "eslint src --fix", "typecheck": "tsc --noEmit", - "test": "echo 'no tests yet'" + "test": "vitest run", + "test:cov": "vitest run --coverage" }, "dependencies": { "@meshitrack/shared": "*", @@ -23,12 +24,21 @@ "devDependencies": { "@next/eslint-plugin-next": "^16.2.1", "@tailwindcss/postcss": "^4.2.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^29.0.1", + "msw": "^2.12.14", "tailwindcss": "^4.2.0", - "typescript": "^6.0.0" + "typescript": "^6.0.0", + "vitest": "^4.1.2" } } diff --git a/packages/web/src/app/(dashboard)/__tests__/layout.test.tsx b/packages/web/src/app/(dashboard)/__tests__/layout.test.tsx new file mode 100644 index 0000000..96d506d --- /dev/null +++ b/packages/web/src/app/(dashboard)/__tests__/layout.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('@/components/layout/Sidebar', () => ({ + Sidebar: () =>