From 7f8fbb3f74a2ab45ec5155f5dc053e8fb900b067 Mon Sep 17 00:00:00 2001 From: omarkobro Date: Thu, 20 Aug 2026 02:57:21 +0300 Subject: [PATCH] added redis caching for getting (for coach) a specific client plan (workout or nutrition) --- coachhub/services/core-api/.env.example | 3 + coachhub/services/core-api/package-lock.json | 167 ++++++++++++++++-- coachhub/services/core-api/package.json | 4 + .../src/cache/plan-builder-cache.module.ts | 24 +++ .../src/cache/plan-builder-cache.service.ts | 73 ++++++++ .../core-api/src/config/config.interface.ts | 5 + .../core-api/src/config/config.service.ts | 4 + .../core-api/src/config/config.validation.ts | 3 + .../core-api/src/config/configuration.ts | 4 + .../src/plans/nutrition/nutrition.module.ts | 2 + .../client-nutrition-plans.service.ts | 21 ++- .../services/nutrition-plan-days.service.ts | 7 + .../nutrition-plan-lifecycle.service.ts | 27 +++ .../services/planned-meals.service.ts | 46 ++++- .../services/client-programs.service.ts | 21 ++- .../services/planned-exercises.service.ts | 158 ++++++++++------- .../training/services/program-days.service.ts | 14 +- .../services/program-lifecycle.service.ts | 27 +++ .../src/plans/training/training.module.ts | 2 + 19 files changed, 528 insertions(+), 84 deletions(-) create mode 100644 coachhub/services/core-api/src/cache/plan-builder-cache.module.ts create mode 100644 coachhub/services/core-api/src/cache/plan-builder-cache.service.ts diff --git a/coachhub/services/core-api/.env.example b/coachhub/services/core-api/.env.example index ab1424f..89d0d69 100644 --- a/coachhub/services/core-api/.env.example +++ b/coachhub/services/core-api/.env.example @@ -9,6 +9,9 @@ DATABASE_URL=postgresql://coachhub:secret@localhost:5432/coachhub # RabbitMQ RABBITMQ_URL=amqp://coachhub:secret@localhost:5672 +# Redis (disposable cache) +REDIS_URL=redis://localhost:6379 + # JWT JWT_ACCESS_SECRET=changeme JWT_REFRESH_SECRET=changeme diff --git a/coachhub/services/core-api/package-lock.json b/coachhub/services/core-api/package-lock.json index 4681c46..c020bc7 100644 --- a/coachhub/services/core-api/package-lock.json +++ b/coachhub/services/core-api/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@golevelup/nestjs-rabbitmq": "^5.7.0", + "@keyv/redis": "^5.1.6", + "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", @@ -26,10 +28,12 @@ "amqplib": "^0.10.3", "aws-sdk": "^2.1693.0", "bcrypt": "^6.0.0", + "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "google-auth-library": "^10.9.0", "helmet": "^8.2.0", + "keyv": "^5.6.0", "multer": "^2.2.0", "passport-jwt": "^4.0.1", "pg": "^8.11.0", @@ -269,6 +273,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -764,6 +769,16 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2135,6 +2150,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/redis": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-5.1.6.tgz", + "integrity": "sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==", + "license": "MIT", + "dependencies": { + "@redis/client": "^5.10.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.13.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@ljharb/through": { "version": "2.3.14", "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", @@ -2163,6 +2201,19 @@ "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", "license": "MIT" }, + "node_modules/@nestjs/cache-manager": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.3.tgz", + "integrity": "sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", + "cache-manager": ">=6", + "keyv": ">=5", + "rxjs": "^7.8.1" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -2306,6 +2357,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2352,6 +2404,7 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2422,6 +2475,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-10.4.22.tgz", "integrity": "sha512-9Oxc0jQuppGLaQv5yaB2tVS2rAZzZ9NqDS1A4UlDLiYwJB7M6e89G6tmyOQjGjPwgoXPxQS4Vg2voSiKiED2gw==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "tslib": "2.8.1" @@ -2490,6 +2544,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", + "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -2529,6 +2584,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz", "integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==", "license": "MIT", + "peer": true, "dependencies": { "socket.io": "4.8.1", "tslib": "2.8.1" @@ -2726,6 +2782,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", "license": "MIT", + "peer": true, "dependencies": { "uuid": "9.0.1" }, @@ -2756,6 +2813,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz", "integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==", "license": "MIT", + "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -2840,6 +2898,30 @@ "node": ">=14" } }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -3164,6 +3246,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3342,6 +3425,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3699,6 +3783,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3712,7 +3797,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -3758,6 +3842,7 @@ "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4393,6 +4478,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4493,6 +4579,17 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.9.tgz", + "integrity": "sha512-d4vceEyYe95gPxEyQchlEOH9vJlkNRW8G6gzFzzMTxJK9PahYMhC9chrEqgZN0HulROjgw3IzmWVNk7Q7ytiGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/utils": "^2.5.0", + "keyv": "^5.6.0" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -4687,13 +4784,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -4788,6 +4887,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5371,8 +5479,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.2", @@ -5421,6 +5528,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6032,6 +6140,16 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -6598,6 +6716,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6622,6 +6752,12 @@ "url": "https://github.com/sponsors/EvanHahn" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7167,6 +7303,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8002,13 +8139,13 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", + "peer": true, "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/kleur": { @@ -8900,14 +9037,14 @@ "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==", - "peer": true + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -9703,6 +9840,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10798,6 +10936,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10956,6 +11095,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz", "integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -11144,6 +11284,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11485,7 +11626,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11500,7 +11640,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -11511,7 +11650,6 @@ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -11522,7 +11660,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/coachhub/services/core-api/package.json b/coachhub/services/core-api/package.json index e3073ee..2a8c0ac 100644 --- a/coachhub/services/core-api/package.json +++ b/coachhub/services/core-api/package.json @@ -24,6 +24,8 @@ }, "dependencies": { "@golevelup/nestjs-rabbitmq": "^5.7.0", + "@keyv/redis": "^5.1.6", + "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", @@ -41,10 +43,12 @@ "amqplib": "^0.10.3", "aws-sdk": "^2.1693.0", "bcrypt": "^6.0.0", + "cache-manager": "^7.2.9", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "google-auth-library": "^10.9.0", "helmet": "^8.2.0", + "keyv": "^5.6.0", "multer": "^2.2.0", "passport-jwt": "^4.0.1", "pg": "^8.11.0", diff --git a/coachhub/services/core-api/src/cache/plan-builder-cache.module.ts b/coachhub/services/core-api/src/cache/plan-builder-cache.module.ts new file mode 100644 index 0000000..05903fc --- /dev/null +++ b/coachhub/services/core-api/src/cache/plan-builder-cache.module.ts @@ -0,0 +1,24 @@ +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; +import { createKeyvNonBlocking } from '@keyv/redis'; +import { ConfigModule, ConfigService } from '../config'; +import { PlanBuilderCacheService } from './plan-builder-cache.service'; + +@Module({ + imports: [ + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + stores: [ + createKeyvNonBlocking(configService.redisConfig.url, { + namespace: 'coachhub', + }), + ], + }), + }), + ], + providers: [PlanBuilderCacheService], + exports: [PlanBuilderCacheService], +}) +export class PlanBuilderCacheModule {} diff --git a/coachhub/services/core-api/src/cache/plan-builder-cache.service.ts b/coachhub/services/core-api/src/cache/plan-builder-cache.service.ts new file mode 100644 index 0000000..ab97e85 --- /dev/null +++ b/coachhub/services/core-api/src/cache/plan-builder-cache.service.ts @@ -0,0 +1,73 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Cache } from 'cache-manager'; + +export type PlanBuilderType = 'training' | 'nutrition'; + +const PLAN_BUILDER_CACHE_TTL_MS = 5 * 60 * 1000; + +@Injectable() +export class PlanBuilderCacheService { + private readonly logger = new Logger(PlanBuilderCacheService.name); + + constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} + + async getBuilder( + planType: PlanBuilderType, + tenantId: string, + planId: string, + ): Promise { + try { + const cached = await this.cache.get( + this.buildKey(planType, tenantId, planId), + ); + if (cached == null) { + this.logger.debug(`${planType} plan builder cache miss`); + return null; + } + + this.logger.debug(`${planType} plan builder cache hit`); + return cached; + } catch { + this.logger.warn(`${planType} plan builder cache read failed`); + return null; + } + } + + async setBuilder( + planType: PlanBuilderType, + tenantId: string, + planId: string, + response: unknown, + ): Promise { + try { + await this.cache.set( + this.buildKey(planType, tenantId, planId), + response, + PLAN_BUILDER_CACHE_TTL_MS, + ); + } catch { + this.logger.warn(`${planType} plan builder cache write failed`); + } + } + + async invalidateBuilder( + planType: PlanBuilderType, + tenantId: string, + planId: string, + ): Promise { + try { + await this.cache.del(this.buildKey(planType, tenantId, planId)); + } catch { + this.logger.warn(`${planType} plan builder cache invalidation failed`); + } + } + + private buildKey( + planType: PlanBuilderType, + tenantId: string, + planId: string, + ) { + return `plans:v1:${planType}:${tenantId}:${planId}`; + } +} diff --git a/coachhub/services/core-api/src/config/config.interface.ts b/coachhub/services/core-api/src/config/config.interface.ts index 935b6c6..90c6306 100644 --- a/coachhub/services/core-api/src/config/config.interface.ts +++ b/coachhub/services/core-api/src/config/config.interface.ts @@ -15,6 +15,10 @@ export interface RabbitmqConfig { url: string; } +export interface RedisConfig { + url: string; +} + export interface JwtTokenConfig { secret: string; expiresIn: string; @@ -80,6 +84,7 @@ export interface Config { app: AppConfig; database: DatabaseConfig; rabbitmq: RabbitmqConfig; + redis: RedisConfig; jwt: JwtConfig; aws: AwsConfig; imageTypes: ImageTypes; diff --git a/coachhub/services/core-api/src/config/config.service.ts b/coachhub/services/core-api/src/config/config.service.ts index 081a950..e23c102 100644 --- a/coachhub/services/core-api/src/config/config.service.ts +++ b/coachhub/services/core-api/src/config/config.service.ts @@ -18,6 +18,10 @@ export class ConfigService { return this.configService.getOrThrow('rabbitmq'); } + get redisConfig(): Config['redis'] { + return this.configService.getOrThrow('redis'); + } + get jwtConfig(): Config['jwt'] { return this.configService.getOrThrow('jwt'); } diff --git a/coachhub/services/core-api/src/config/config.validation.ts b/coachhub/services/core-api/src/config/config.validation.ts index b584a0a..74df4da 100644 --- a/coachhub/services/core-api/src/config/config.validation.ts +++ b/coachhub/services/core-api/src/config/config.validation.ts @@ -15,6 +15,9 @@ export const ConfigSchema = z.object({ rabbitmq: z.object({ url: z.string(), }), + redis: z.object({ + url: z.string().url(), + }), ai: z.object({ geminiApiKey: z.string(), aiRequestTimeoutMs: z.coerce.number().default(30000), diff --git a/coachhub/services/core-api/src/config/configuration.ts b/coachhub/services/core-api/src/config/configuration.ts index 8d11b3e..c32b285 100644 --- a/coachhub/services/core-api/src/config/configuration.ts +++ b/coachhub/services/core-api/src/config/configuration.ts @@ -30,6 +30,10 @@ export default () => ({ url: process.env.RABBITMQ_URL || 'amqp://localhost:5672', }, + redis: { + url: process.env.REDIS_URL || 'redis://localhost:6379', + }, + jwt: { accessToken: { secret: process.env.JWT_ACCESS_SECRET, diff --git a/coachhub/services/core-api/src/plans/nutrition/nutrition.module.ts b/coachhub/services/core-api/src/plans/nutrition/nutrition.module.ts index c334601..8e88b36 100644 --- a/coachhub/services/core-api/src/plans/nutrition/nutrition.module.ts +++ b/coachhub/services/core-api/src/plans/nutrition/nutrition.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ActivityModule } from '../../activity/activity.module'; +import { PlanBuilderCacheModule } from '../../cache/plan-builder-cache.module'; import { ClientIntake } from '../../clients/entities/client-intake.entity'; import { ClientMembership } from '../../clients/entities/client-membership.entity'; import { ClientNutritionPlansController } from './controllers/client-nutrition-plans.controller'; @@ -38,6 +39,7 @@ import { PlannedMealsService } from './services/planned-meals.service'; @Module({ imports: [ ActivityModule, + PlanBuilderCacheModule, TypeOrmModule.forFeature([ ClientIntake, ClientMembership, diff --git a/coachhub/services/core-api/src/plans/nutrition/services/client-nutrition-plans.service.ts b/coachhub/services/core-api/src/plans/nutrition/services/client-nutrition-plans.service.ts index 326def5..2b8e127 100644 --- a/coachhub/services/core-api/src/plans/nutrition/services/client-nutrition-plans.service.ts +++ b/coachhub/services/core-api/src/plans/nutrition/services/client-nutrition-plans.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { ClientIntake } from '../../../clients/entities/client-intake.entity'; import { ClientMembership } from '../../../clients/entities/client-membership.entity'; import { @@ -38,6 +39,7 @@ export class ClientNutritionPlansService { @InjectRepository(NutritionPlan) private readonly nutritionPlanRepository: Repository, private readonly dataSource: DataSource, + private readonly planBuilderCache: PlanBuilderCacheService, ) {} async createClientPlan( @@ -181,6 +183,11 @@ export class ClientNutritionPlansService { async getClientPlan(tenantId: string | null, planId: string) { const activeTenantId = assertNutritionTenant(tenantId); + const cached = await this.planBuilderCache.getBuilder< + ReturnType + >('nutrition', activeTenantId, planId); + if (cached) return cached; + const plan = await this.nutritionPlanRepository.findOne({ where: { id: planId, @@ -219,11 +226,18 @@ export class ClientNutritionPlansService { }) : null; - return mapClientNutritionPlanBuilder( + const response = mapClientNutritionPlanBuilder( plan, plan.tenant.timezone, dietaryProfile, ); + await this.planBuilderCache.setBuilder( + 'nutrition', + activeTenantId, + planId, + response, + ); + return response; } async updateClientPlan( @@ -280,6 +294,11 @@ export class ClientNutritionPlansService { } await this.nutritionPlanRepository.save(plan); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + plan.id, + ); return this.getClientPlan(activeTenantId, plan.id); } } diff --git a/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-days.service.ts b/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-days.service.ts index db541de..cb8bdb9 100644 --- a/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-days.service.ts +++ b/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-days.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { UpdateNutritionPlanDayDto } from '../dto/nutrition-builder.dto'; import { NutritionPlanDay } from '../entities/nutrition-plan-day.entity'; import { lockEditableNutritionDay } from '../persistence/nutrition-builder.persistence'; @@ -14,6 +15,7 @@ export class NutritionPlanDaysService { constructor( private readonly dataSource: DataSource, private readonly clientNutritionPlansService: ClientNutritionPlansService, + private readonly planBuilderCache: PlanBuilderCacheService, ) {} async updatePlanDay( @@ -58,6 +60,11 @@ export class NutritionPlanDaysService { await manager.getRepository(NutritionPlanDay).save(day); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); const plan = await this.clientNutritionPlansService.getClientPlan( activeTenantId, diff --git a/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-lifecycle.service.ts b/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-lifecycle.service.ts index 6cd2ce9..51d7966 100644 --- a/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-lifecycle.service.ts +++ b/coachhub/services/core-api/src/plans/nutrition/services/nutrition-plan-lifecycle.service.ts @@ -1,5 +1,6 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { deriveInclusiveEndDate, getDateOnlyInTimeZone, @@ -32,6 +33,7 @@ export class NutritionPlanLifecycleService { constructor( private readonly dataSource: DataSource, private readonly clientNutritionPlansService: ClientNutritionPlansService, + private readonly planBuilderCache: PlanBuilderCacheService, ) {} async publishClientPlan(tenantId: string | null, planId: string) { @@ -66,6 +68,11 @@ export class NutritionPlanLifecycleService { plan.status = NutritionPlanStatus.PUBLISHED; await manager.getRepository(NutritionPlan).save(plan); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); return { plan: await this.clientNutritionPlansService.getClientPlan( @@ -112,6 +119,11 @@ export class NutritionPlanLifecycleService { await assertNoPublishedNutritionOverlap(manager, plan); await manager.getRepository(NutritionPlan).save(plan); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); return this.clientNutritionPlansService.getClientPlan( activeTenantId, @@ -132,6 +144,11 @@ export class NutritionPlanLifecycleService { plan.status = NutritionPlanStatus.CANCELLED; await manager.getRepository(NutritionPlan).save(plan); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); return this.clientNutritionPlansService.getClientPlan( activeTenantId, @@ -142,12 +159,22 @@ export class NutritionPlanLifecycleService { async archiveClientPlan(tenantId: string | null, planId: string) { const activeTenantId = assertNutritionTenant(tenantId); await this.setArchived(activeTenantId, planId, true); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); return { message: 'Client nutrition plan archived' }; } async unarchiveClientPlan(tenantId: string | null, planId: string) { const activeTenantId = assertNutritionTenant(tenantId); await this.setArchived(activeTenantId, planId, false); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); return { message: 'Client nutrition plan unarchived' }; } diff --git a/coachhub/services/core-api/src/plans/nutrition/services/planned-meals.service.ts b/coachhub/services/core-api/src/plans/nutrition/services/planned-meals.service.ts index 5e62269..e9860b2 100644 --- a/coachhub/services/core-api/src/plans/nutrition/services/planned-meals.service.ts +++ b/coachhub/services/core-api/src/plans/nutrition/services/planned-meals.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { AddMealFromLibraryDto, CreateLibraryMealAndAddDto, @@ -40,7 +41,10 @@ import { assertRealisticMealFoodAmount } from '../utils/nutrition-validation.uti @Injectable() export class PlannedMealsService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly planBuilderCache: PlanBuilderCacheService, + ) {} async addMealFromLibrary( tenantId: string | null, @@ -49,7 +53,7 @@ export class PlannedMealsService { body: AddMealFromLibraryDto, ) { const activeTenantId = assertNutritionTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const plannedMeal = await this.dataSource.transaction(async (manager) => { const day = await lockEditableNutritionDay( manager, activeTenantId, @@ -63,6 +67,12 @@ export class PlannedMealsService { ); return insertPlannedMealSnapshot(manager, day, meal, body); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); + return plannedMeal; } async createLibraryMealAndAdd( @@ -76,7 +86,7 @@ export class PlannedMealsService { assertUniqueMealFoods(body.meal.items); try { - return await this.dataSource.transaction(async (manager) => { + const result = await this.dataSource.transaction(async (manager) => { const day = await lockEditableNutritionDay( manager, activeTenantId, @@ -161,6 +171,12 @@ export class PlannedMealsService { plannedMeal, }; }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); + return result; } catch (error) { throwMealConflictForUniqueViolation(error); throw error; @@ -174,7 +190,7 @@ export class PlannedMealsService { body: UpdatePlannedMealDto, ) { const activeTenantId = assertNutritionTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const updatedMeal = await this.dataSource.transaction(async (manager) => { const planned = await getEditablePlannedMeal( manager, activeTenantId, @@ -218,6 +234,12 @@ export class PlannedMealsService { ); return mapPlannedMealResponse(updated); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); + return updatedMeal; } async replacePlannedMealItems( @@ -229,7 +251,7 @@ export class PlannedMealsService { const activeTenantId = assertNutritionTenant(tenantId); assertUniquePlannedFoodIds(body.items); - return this.dataSource.transaction(async (manager) => { + const updatedMeal = await this.dataSource.transaction(async (manager) => { const planned = await getEditablePlannedMeal( manager, activeTenantId, @@ -275,6 +297,12 @@ export class PlannedMealsService { ); return mapPlannedMealResponse(updated); }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); + return updatedMeal; } async deletePlannedMeal( @@ -283,7 +311,7 @@ export class PlannedMealsService { plannedMealId: string, ) { const activeTenantId = assertNutritionTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const result = await this.dataSource.transaction(async (manager) => { const planned = await getEditablePlannedMeal( manager, activeTenantId, @@ -303,5 +331,11 @@ export class PlannedMealsService { await rewritePlannedMealPositions(manager, remaining, remaining); return { message: 'Planned Meal deleted' }; }); + await this.planBuilderCache.invalidateBuilder( + 'nutrition', + activeTenantId, + planId, + ); + return result; } } diff --git a/coachhub/services/core-api/src/plans/training/services/client-programs.service.ts b/coachhub/services/core-api/src/plans/training/services/client-programs.service.ts index 38ba3f6..2b94ff9 100644 --- a/coachhub/services/core-api/src/plans/training/services/client-programs.service.ts +++ b/coachhub/services/core-api/src/plans/training/services/client-programs.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { ClientMembership } from '../../../clients/entities/client-membership.entity'; import { MembershipStatus, ProgramStatus, ProgramType } from '../../../common'; import { Tenant } from '../../../tenant/entities/tenant.entity'; @@ -31,6 +32,7 @@ export class ClientProgramsService { @InjectRepository(Program) private readonly programRepository: Repository, private readonly dataSource: DataSource, + private readonly planBuilderCache: PlanBuilderCacheService, ) {} async createClientProgram( @@ -160,6 +162,11 @@ export class ClientProgramsService { async getClientProgram(tenantId: string | null, programId: string) { const activeTenantId = assertActiveTenant(tenantId); + const cached = await this.planBuilderCache.getBuilder< + ReturnType + >('training', activeTenantId, programId); + if (cached) return cached; + const program = await this.programRepository.findOne({ where: { id: programId, @@ -198,7 +205,14 @@ export class ClientProgramsService { throw new NotFoundException('Tenant not found'); } - return mapBuilderProgram(program, tenant.timezone); + const response = mapBuilderProgram(program, tenant.timezone); + await this.planBuilderCache.setBuilder( + 'training', + activeTenantId, + programId, + response, + ); + return response; } async updateClientProgram( @@ -245,6 +259,11 @@ export class ClientProgramsService { } await this.programRepository.save(program); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + program.id, + ); return this.getClientProgram(activeTenantId, program.id); } } diff --git a/coachhub/services/core-api/src/plans/training/services/planned-exercises.service.ts b/coachhub/services/core-api/src/plans/training/services/planned-exercises.service.ts index 9574105..27d1caa 100644 --- a/coachhub/services/core-api/src/plans/training/services/planned-exercises.service.ts +++ b/coachhub/services/core-api/src/plans/training/services/planned-exercises.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { DataSource, QueryFailedError } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { Exercise } from '../../../exercises/entities/exercise.entity'; import { PrescribeExerciseDto } from '../dto/prescribe-exercise.dto'; import { @@ -30,7 +31,10 @@ import { @Injectable() export class PlannedExercisesService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly planBuilderCache: PlanBuilderCacheService, + ) {} async addExerciseFromLibrary( tenantId: string | null, @@ -39,28 +43,36 @@ export class PlannedExercisesService { body: PrescribeExerciseDto, ) { const activeTenantId = assertActiveTenant(tenantId); - return this.dataSource.transaction(async (manager) => { - const day = await lockEditableDay( - manager, - activeTenantId, - programId, - programDayId, - ); - assertWorkoutDay(day); + const plannedExercise = await this.dataSource.transaction( + async (manager) => { + const day = await lockEditableDay( + manager, + activeTenantId, + programId, + programDayId, + ); + assertWorkoutDay(day); - const exercise = await manager.getRepository(Exercise).findOne({ - where: { - id: body.exerciseId, - tenantId: activeTenantId, - isActive: true, - }, - }); - if (!exercise) { - throw new NotFoundException('Active library exercise not found'); - } + const exercise = await manager.getRepository(Exercise).findOne({ + where: { + id: body.exerciseId, + tenantId: activeTenantId, + isActive: true, + }, + }); + if (!exercise) { + throw new NotFoundException('Active library exercise not found'); + } - return insertExerciseSnapshot(manager, day, exercise, body); - }); + return insertExerciseSnapshot(manager, day, exercise, body); + }, + ); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); + return plannedExercise; } async createLibraryExerciseAndAdd( @@ -71,7 +83,7 @@ export class PlannedExercisesService { body: CreateAndPrescribeExerciseDto, ) { const activeTenantId = assertActiveTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const result = await this.dataSource.transaction(async (manager) => { const day = await lockEditableDay( manager, activeTenantId, @@ -129,6 +141,12 @@ export class PlannedExercisesService { ); return { exercise, plannedExercise }; }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); + return result; } async updatePlannedExercise( @@ -138,46 +156,56 @@ export class PlannedExercisesService { body: UpdatePlannedExerciseDto, ) { const activeTenantId = assertActiveTenant(tenantId); - return this.dataSource.transaction(async (manager) => { - const planned = await getEditablePlannedExercise( - manager, - activeTenantId, - programId, - plannedExerciseId, - ); - const repository = manager.getRepository(PlannedExercise); + const updatedExercise = await this.dataSource.transaction( + async (manager) => { + const planned = await getEditablePlannedExercise( + manager, + activeTenantId, + programId, + plannedExerciseId, + ); + const repository = manager.getRepository(PlannedExercise); - if (body.position !== undefined && body.position !== planned.position) { - const ordered = await repository.find({ - where: { programDayId: planned.programDayId }, - order: { position: 'ASC' }, - }); - if (body.position > ordered.length) { - throw new BadRequestException( - `position must be between 1 and ${ordered.length}`, + if (body.position !== undefined && body.position !== planned.position) { + const ordered = await repository.find({ + where: { programDayId: planned.programDayId }, + order: { position: 'ASC' }, + }); + if (body.position > ordered.length) { + throw new BadRequestException( + `position must be between 1 and ${ordered.length}`, + ); + } + const withoutCurrent = ordered.filter( + (item) => item.id !== planned.id, ); + withoutCurrent.splice(body.position - 1, 0, planned); + await rewriteExercisePositions(manager, ordered, withoutCurrent); + planned.position = body.position; } - const withoutCurrent = ordered.filter((item) => item.id !== planned.id); - withoutCurrent.splice(body.position - 1, 0, planned); - await rewriteExercisePositions(manager, ordered, withoutCurrent); - planned.position = body.position; - } - if (body.supersetGroup !== undefined) - planned.supersetGroup = body.supersetGroup; - if (body.restSeconds !== undefined) - planned.restSeconds = body.restSeconds; - if (body.tempo !== undefined) planned.tempo = body.tempo; - if (body.coachNotes !== undefined) - planned.coachNotes = normalizeOptionalText(body.coachNotes); + if (body.supersetGroup !== undefined) + planned.supersetGroup = body.supersetGroup; + if (body.restSeconds !== undefined) + planned.restSeconds = body.restSeconds; + if (body.tempo !== undefined) planned.tempo = body.tempo; + if (body.coachNotes !== undefined) + planned.coachNotes = normalizeOptionalText(body.coachNotes); - await repository.save(planned); - return repository.findOne({ - where: { id: planned.id }, - relations: { sets: true }, - order: { sets: { setNumber: 'ASC' } }, - }); - }); + await repository.save(planned); + return repository.findOne({ + where: { id: planned.id }, + relations: { sets: true }, + order: { sets: { setNumber: 'ASC' } }, + }); + }, + ); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); + return updatedExercise; } async replacePlannedSets( @@ -187,7 +215,7 @@ export class PlannedExercisesService { body: ReplacePlannedSetsDto, ) { const activeTenantId = assertActiveTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const sets = await this.dataSource.transaction(async (manager) => { const planned = await getEditablePlannedExercise( manager, activeTenantId, @@ -207,6 +235,12 @@ export class PlannedExercisesService { ); return setRepository.save(sets); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); + return sets; } async deletePlannedExercise( @@ -215,7 +249,7 @@ export class PlannedExercisesService { plannedExerciseId: string, ) { const activeTenantId = assertActiveTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const result = await this.dataSource.transaction(async (manager) => { const planned = await getEditablePlannedExercise( manager, activeTenantId, @@ -232,5 +266,11 @@ export class PlannedExercisesService { await rewriteExercisePositions(manager, remaining, remaining); return { message: 'Planned exercise deleted' }; }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); + return result; } } diff --git a/coachhub/services/core-api/src/plans/training/services/program-days.service.ts b/coachhub/services/core-api/src/plans/training/services/program-days.service.ts index f0d966a..7208f8c 100644 --- a/coachhub/services/core-api/src/plans/training/services/program-days.service.ts +++ b/coachhub/services/core-api/src/plans/training/services/program-days.service.ts @@ -1,5 +1,6 @@ import { ConflictException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { UpdateProgramDayDto } from '../dto/workout-builder.dto'; import { PlannedExercise } from '../entities/planned-exercise.entity'; import { ProgramDay } from '../entities/program-day.entity'; @@ -11,7 +12,10 @@ import { @Injectable() export class ProgramDaysService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly planBuilderCache: PlanBuilderCacheService, + ) {} async updateProgramDay( tenantId: string | null, @@ -20,7 +24,7 @@ export class ProgramDaysService { body: UpdateProgramDayDto, ) { const activeTenantId = assertActiveTenant(tenantId); - return this.dataSource.transaction(async (manager) => { + const updatedDay = await this.dataSource.transaction(async (manager) => { const day = await lockEditableDay( manager, activeTenantId, @@ -53,5 +57,11 @@ export class ProgramDaysService { return manager.getRepository(ProgramDay).save(day); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); + return updatedDay; } } diff --git a/coachhub/services/core-api/src/plans/training/services/program-lifecycle.service.ts b/coachhub/services/core-api/src/plans/training/services/program-lifecycle.service.ts index fd11f5e..4115ada 100644 --- a/coachhub/services/core-api/src/plans/training/services/program-lifecycle.service.ts +++ b/coachhub/services/core-api/src/plans/training/services/program-lifecycle.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; +import { PlanBuilderCacheService } from '../../../cache/plan-builder-cache.service'; import { ClientMembership } from '../../../clients/entities/client-membership.entity'; import { MembershipStatus, ProgramStatus, ProgramType } from '../../../common'; import { RescheduleClientProgramDto } from '../dto/program-lifecycle.dto'; @@ -31,6 +32,7 @@ export class ProgramLifecycleService { constructor( private readonly dataSource: DataSource, private readonly clientProgramsService: ClientProgramsService, + private readonly planBuilderCache: PlanBuilderCacheService, ) {} async publishClientProgram(tenantId: string | null, programId: string) { @@ -58,6 +60,11 @@ export class ProgramLifecycleService { program.status = ProgramStatus.PUBLISHED; await manager.getRepository(Program).save(program); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); return this.clientProgramsService.getClientProgram( activeTenantId, @@ -104,6 +111,11 @@ export class ProgramLifecycleService { await assertNoPublishedOverlap(manager, program); await manager.getRepository(Program).save(program); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); return this.clientProgramsService.getClientProgram( activeTenantId, @@ -126,6 +138,11 @@ export class ProgramLifecycleService { program.status = ProgramStatus.CANCELLED; await manager.getRepository(Program).save(program); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); return this.clientProgramsService.getClientProgram( activeTenantId, @@ -144,6 +161,11 @@ export class ProgramLifecycleService { program.isArchived = true; await manager.getRepository(Program).save(program); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); return { message: 'Client program archived' }; } @@ -159,6 +181,11 @@ export class ProgramLifecycleService { program.isArchived = false; await manager.getRepository(Program).save(program); }); + await this.planBuilderCache.invalidateBuilder( + 'training', + activeTenantId, + programId, + ); return this.clientProgramsService.getClientProgram( activeTenantId, diff --git a/coachhub/services/core-api/src/plans/training/training.module.ts b/coachhub/services/core-api/src/plans/training/training.module.ts index 1eb21c9..e64f238 100644 --- a/coachhub/services/core-api/src/plans/training/training.module.ts +++ b/coachhub/services/core-api/src/plans/training/training.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ActivityModule } from '../../activity/activity.module'; +import { PlanBuilderCacheModule } from '../../cache/plan-builder-cache.module'; import { ClientMembership } from '../../clients/entities/client-membership.entity'; import { Exercise } from '../../exercises/entities/exercise.entity'; import { ClientTrainingController } from './client-training.controller'; @@ -27,6 +28,7 @@ import { WorkoutLogReviewService } from './services/workout-log-review.service'; @Module({ imports: [ ActivityModule, + PlanBuilderCacheModule, TypeOrmModule.forFeature([ Program, ProgramWeek,