From c48dc3cd0b0cb40d0d7c131ce0743608eed9c10e Mon Sep 17 00:00:00 2001 From: Gianpierre Mio <88248836+DarkCodex29@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:43:25 -0500 Subject: [PATCH] feat: add Redis cache, gzip, CI/CD via Gitea self-hosted runner --- .DS_Store | Bin 6148 -> 6148 bytes .github/.DS_Store | Bin 6148 -> 6148 bytes .github/workflows/ci.yml | 31 +++ .github/workflows/deploy.yml | 47 ++-- .gitignore | 2 + Dockerfile | 8 + docker-compose.production.yml | 30 ++- package-lock.json | 216 ++++++++++++++-- package.json | 7 +- src/.DS_Store | Bin 6148 -> 6148 bytes src/app.module.ts | 15 ++ src/main.ts | 2 + src/modules/.DS_Store | Bin 6148 -> 6148 bytes src/modules/paises/paises.controller.ts | 8 +- src/modules/paises/paises.service.ts | 124 ++++++---- src/modules/ubigeo/ubigeo.controller.ts | 12 +- src/modules/ubigeo/ubigeo.service.ts | 313 +++++++++++++----------- 17 files changed, 567 insertions(+), 248 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.DS_Store b/.DS_Store index 2e88d241a9dbc1d72f9b09d6345d5d8d9fa04a78..1766e61e715e74a0a2beef89defdf0ecae1715e3 100644 GIT binary patch delta 352 zcmZoMXfc@JFDS#nz`)4BAi$85ZWx@LpIfk5kYhD-JxG{^L60GwA(Np5CIu7&ik?4; zESH<_;*yk;p9B=+(3t-?|IPa2j;Qh}c;yQ+Al3r)Ffec_fJk-*y~#o>(u_AI>#@kO zyxsVwadH5Q5{w}>xrc?1(P8pz7U{UWyFleE3@JcI7BiF}JLUUf|IM#qPJzoKoPtdr z#VPCu7$!Tg%1kz3;{sqX^V0}}sw&3;TTvlx;Yc8ucv$DX0$){NtGEICmIgc%b QF=6sywlkaAIsWnk0QUT4&j0`b delta 226 zcmZoMXfc@JFDk*nz`)4BAi%(o!BEPOl#^~4oSeTgaXB-i#N-whMaCJEm$AsPyjXat zcJezGvB~#XcwihAIRl_x1{Q`ChD?THh7yR@+yamw1FK-}WCKwdFQkB#~q;(DOe@=3o<}%gjl3h gHes?e+d`%miOJ8{LKp)kTeF{;SWvN + redis-server + --maxmemory 128mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - ubigeo_network + api: image: ubigeo-api:latest container_name: ubigeo-api @@ -26,23 +46,21 @@ services: - "127.0.0.1:3200:3200" environment: DATABASE_URL: "postgresql://ubigeo_user:UbigeoDB2026@postgres:5432/ubigeo_db" + REDIS_URL: "redis://ubigeo-redis:6379" PORT: 3200 NODE_ENV: production depends_on: postgres: condition: service_healthy - labels: - - "traefik.enable=true" - - "traefik.http.routers.ubigeo.rule=Host(`api-ubigeo.darkcodex.dev`)" - - "traefik.http.routers.ubigeo.entrypoints=websecure" - - "traefik.http.routers.ubigeo.tls=true" - - "traefik.http.services.ubigeo.loadbalancer.server.port=3200" + redis: + condition: service_healthy networks: - ubigeo_network - easypanel volumes: postgres_data: + redis_data: networks: ubigeo_network: diff --git a/package-lock.json b/package-lock.json index 8f3b9b7..ceef3d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@keyv/redis": "^5.1.6", + "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", @@ -17,8 +19,12 @@ "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@types/compression": "^1.8.1", + "cache-manager": "^7.2.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "compression": "^1.8.1", + "dotenv": "^17.3.1", "helmet": "^8.1.0", "pg": "^8.20.0", "prisma": "^7.4.2", @@ -36,7 +42,6 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", - "dotenv": "^17.3.1", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -724,6 +729,16 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cacheable/utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz", + "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.0", + "keyv": "^5.6.0" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", @@ -2130,6 +2145,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/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2171,6 +2209,19 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.0.tgz", + "integrity": "sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==", + "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": "11.0.16", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", @@ -2937,6 +2988,26 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + } + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -3088,18 +3159,26 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3146,7 +3225,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -3158,7 +3236,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3171,7 +3248,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -3230,7 +3306,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3241,14 +3316,12 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -3265,7 +3338,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3275,7 +3347,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -4690,6 +4761,17 @@ "url": "https://dotenvx.com" } }, + "node_modules/cache-manager": { + "version": "7.2.8", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", + "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/utils": "^2.3.3", + "keyv": "^5.5.5" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4985,6 +5067,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", @@ -5071,6 +5162,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5386,7 +5531,6 @@ "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -6168,6 +6312,16 @@ "node": ">=16" } }, + "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.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", @@ -6650,6 +6804,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz", + "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==", + "license": "MIT", + "dependencies": { + "hookified": "^1.14.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6681,6 +6847,12 @@ "node": ">=16.9.0" } }, + "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", @@ -7941,13 +8113,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/leven": { @@ -8595,6 +8767,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10706,7 +10887,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index ec40936..59e6c59 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@keyv/redis": "^5.1.6", + "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", @@ -32,8 +34,12 @@ "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@types/compression": "^1.8.1", + "cache-manager": "^7.2.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "compression": "^1.8.1", + "dotenv": "^17.3.1", "helmet": "^8.1.0", "pg": "^8.20.0", "prisma": "^7.4.2", @@ -51,7 +57,6 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", - "dotenv": "^17.3.1", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/src/.DS_Store b/src/.DS_Store index 5325c6e0a10a2fe5d2357dcbc9eba0a2817a48ee..e33696feecffbedfa29fbae921c3e915372e1bb0 100644 GIT binary patch delta 21 ccmZoMXffEJ!^D(fJz0+_hw;MZ*-V+D07=;fx&QzG delta 21 ccmZoMXffEJ!^9Lbd9of;4&#B%vzanQ0Zjb{_5c6? diff --git a/src/app.module.ts b/src/app.module.ts index af6c8dc..a45c06c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,16 +1,31 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { CacheModule } from '@nestjs/cache-manager'; +import KeyvRedis from '@keyv/redis'; import { APP_GUARD } from '@nestjs/core'; import { PrismaModule } from './prisma/prisma.module'; import { UbigeoModule } from './modules/ubigeo/ubigeo.module'; import { PaisesModule } from './modules/paises/paises.module'; import { HealthModule } from './modules/health/health.module'; +const TTL_24H = 24 * 60 * 60 * 1000; + @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), ThrottlerModule.forRoot([{ ttl: 60000, limit: 200 }]), + CacheModule.registerAsync({ + isGlobal: true, + useFactory: () => { + const redisUrl = process.env.REDIS_URL; + if (redisUrl) { + return { stores: [new KeyvRedis(redisUrl)], ttl: TTL_24H }; + } + // fallback: in-memory para desarrollo local + return { stores: [], ttl: TTL_24H }; + }, + }), PrismaModule, UbigeoModule, PaisesModule, diff --git a/src/main.ts b/src/main.ts index 9acf853..d17612c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import * as helmet from 'helmet'; +import compression = require('compression'); import { AppModule } from './app.module'; async function bootstrap() { @@ -12,6 +13,7 @@ async function bootstrap() { app.enableCors({ origin: '*' }); app.use(helmet.default()); + app.use(compression()); app.useGlobalPipes( new ValidationPipe({ diff --git a/src/modules/.DS_Store b/src/modules/.DS_Store index 6d52dcb885f2fbd3c60c9e97c21b1580b313cf8d..0f07fc56a2c79e63cb99a28550c9f15def3920a9 100644 GIT binary patch delta 67 zcmZoMXffEZiAhoL5d#AQ8$$s@B10xaF+(atac;hgOHxjL5>TAugL3EO%S`D^)|HcO Wn9~>&Hg9AOW0}~%x0#*eFFyc+-xJ>e delta 59 zcmZoMXffEZiAj`q1p@;E8$$s@B10xaF+(atac;iL>wys} diff --git a/src/modules/paises/paises.controller.ts b/src/modules/paises/paises.controller.ts index 998cdb6..e5b5aad 100644 --- a/src/modules/paises/paises.controller.ts +++ b/src/modules/paises/paises.controller.ts @@ -1,21 +1,26 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Controller, Get, Param, Query, Header } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; import { PaisesService } from './paises.service'; +const CACHE_1DAY = 'public, max-age=86400, stale-while-revalidate=3600'; + @ApiTags('paises') @Controller('paises') export class PaisesController { constructor(private readonly svc: PaisesService) {} @Get('stats') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Estadísticas de países' }) getStats() { return this.svc.getStats(); } @Get('regiones') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Listar regiones del mundo' }) getRegiones() { return this.svc.getRegiones(); } @Get() + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Listar países (filtrar con ?q= o ?region=)' }) @ApiQuery({ name: 'q', required: false, description: 'Buscar por nombre o código ISO' }) @ApiQuery({ name: 'region', required: false, description: 'Filtrar por región' }) @@ -24,6 +29,7 @@ export class PaisesController { } @Get(':codigo') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Obtener país por código ISO (PE, PER, etc.)' }) @ApiParam({ name: 'codigo', description: 'Código ISO alpha-2 (PE) o alpha-3 (PER)', example: 'PE' }) getPais(@Param('codigo') codigo: string) { diff --git a/src/modules/paises/paises.service.ts b/src/modules/paises/paises.service.ts index 31d381c..ec1c69e 100644 --- a/src/modules/paises/paises.service.ts +++ b/src/modules/paises/paises.service.ts @@ -1,70 +1,92 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; import { PrismaService } from '../../prisma/prisma.service'; @Injectable() export class PaisesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Inject(CACHE_MANAGER) private cache: Cache, + ) {} + + private async cached(key: string, fn: () => Promise): Promise { + const hit = await this.cache.get(key); + if (hit !== undefined && hit !== null) return hit; + const result = await fn(); + await this.cache.set(key, result); + return result; + } async getPaises(search?: string, region?: string) { - const where: any = { activo: true }; - if (search) { - where.OR = [ - { nombre: { contains: search, mode: 'insensitive' } }, - { nombreEn: { contains: search, mode: 'insensitive' } }, - { codigo: { equals: search.toUpperCase() } }, - { codigoAlpha2: { equals: search.toUpperCase() } }, - ]; - } - if (region) { - where.region = { contains: region, mode: 'insensitive' }; - } - return this.prisma.pais.findMany({ - where, - orderBy: { nombre: 'asc' }, - select: { - codigo: true, - codigoAlpha2: true, - nombre: true, - nombreEn: true, - capital: true, - region: true, - subregion: true, - emoji: true, - latitud: true, - longitud: true, - }, + const key = `paises:list:${search ?? ''}:${region ?? ''}`; + return this.cached(key, () => { + const where: any = { activo: true }; + if (search) { + where.OR = [ + { nombre: { contains: search, mode: 'insensitive' } }, + { nombreEn: { contains: search, mode: 'insensitive' } }, + { codigo: { equals: search.toUpperCase() } }, + { codigoAlpha2: { equals: search.toUpperCase() } }, + ]; + } + if (region) { + where.region = { contains: region, mode: 'insensitive' }; + } + return this.prisma.pais.findMany({ + where, + orderBy: { nombre: 'asc' }, + select: { + codigo: true, + codigoAlpha2: true, + nombre: true, + nombreEn: true, + capital: true, + region: true, + subregion: true, + emoji: true, + latitud: true, + longitud: true, + }, + }); }); } async getPais(codigo: string) { - const where = codigo.length === 2 - ? { codigoAlpha2: codigo.toUpperCase() } - : { codigo: codigo.toUpperCase() }; + return this.cached(`pais:${codigo.toUpperCase()}`, async () => { + const where = codigo.length === 2 + ? { codigoAlpha2: codigo.toUpperCase() } + : { codigo: codigo.toUpperCase() }; - const pais = await this.prisma.pais.findFirst({ where }); - if (!pais) throw new NotFoundException(`País '${codigo}' no encontrado`); - return pais; + const pais = await this.prisma.pais.findFirst({ where }); + if (!pais) throw new NotFoundException(`País '${codigo}' no encontrado`); + return pais; + }); } async getRegiones() { - const result = await this.prisma.pais.findMany({ - where: { activo: true }, - distinct: ['region'], - select: { region: true }, - orderBy: { region: 'asc' }, - }); - return result.map((r) => r.region); - } - - async getStats() { - const [total, regiones] = await Promise.all([ - this.prisma.pais.count({ where: { activo: true } }), - this.prisma.pais.findMany({ + return this.cached('paises:regiones', async () => { + const result = await this.prisma.pais.findMany({ where: { activo: true }, distinct: ['region'], select: { region: true }, - }), - ]); - return { total, regiones: regiones.length }; + orderBy: { region: 'asc' }, + }); + return result.map((r) => r.region); + }); + } + + async getStats() { + return this.cached('stats:paises', async () => { + const [total, regiones] = await Promise.all([ + this.prisma.pais.count({ where: { activo: true } }), + this.prisma.pais.findMany({ + where: { activo: true }, + distinct: ['region'], + select: { region: true }, + }), + ]); + return { total, regiones: regiones.length }; + }); } } diff --git a/src/modules/ubigeo/ubigeo.controller.ts b/src/modules/ubigeo/ubigeo.controller.ts index c9d9b7d..d00d0df 100644 --- a/src/modules/ubigeo/ubigeo.controller.ts +++ b/src/modules/ubigeo/ubigeo.controller.ts @@ -1,8 +1,10 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Controller, Get, Param, Query, Header } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiParam, ApiOkResponse } from '@nestjs/swagger'; import { UbigeoService } from './ubigeo.service'; import { SearchDto } from './dto/ubigeo.dto'; +const CACHE_1DAY = 'public, max-age=86400, stale-while-revalidate=3600'; + @ApiTags('ubigeo') @Controller('ubigeo') export class UbigeoController { @@ -10,6 +12,7 @@ export class UbigeoController { // ─── STATS ───────────────────────────────────────────────────────────────── @Get('stats') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Estadísticas generales del ubigeo' }) getStats() { return this.svc.getStats(); @@ -17,6 +20,7 @@ export class UbigeoController { // ─── LOOKUP ───────────────────────────────────────────────────────────────── @Get('lookup/:codigo') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Lookup de cualquier código ubigeo (2, 4 o 6 dígitos)' }) @ApiParam({ name: 'codigo', description: 'Código ubigeo: 15 | 1501 | 150101', example: '150101' }) lookup(@Param('codigo') codigo: string) { @@ -25,6 +29,7 @@ export class UbigeoController { // ─── DEPARTAMENTOS ────────────────────────────────────────────────────────── @Get('departamentos') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Listar todos los departamentos del Perú' }) getDepartamentos(@Query() query: SearchDto) { if (query.q) return this.svc.searchDepartamentos(query); @@ -32,6 +37,7 @@ export class UbigeoController { } @Get('departamentos/:codigo') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Obtener departamento con sus provincias' }) @ApiParam({ name: 'codigo', description: 'Código de 2 dígitos', example: '15' }) getDepartamento(@Param('codigo') codigo: string) { @@ -40,6 +46,7 @@ export class UbigeoController { // ─── PROVINCIAS ───────────────────────────────────────────────────────────── @Get('provincias') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Listar provincias (filtrar por departamento con ?dep=15)' }) getProvincias(@Query() query: SearchDto, @Query('dep') dep?: string) { if (query.q) return this.svc.searchProvincias(query); @@ -47,6 +54,7 @@ export class UbigeoController { } @Get('provincias/:codigo') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Obtener provincia con sus distritos' }) @ApiParam({ name: 'codigo', description: 'Código de 4 dígitos', example: '1501' }) getProvincia(@Param('codigo') codigo: string) { @@ -55,6 +63,7 @@ export class UbigeoController { // ─── DISTRITOS ────────────────────────────────────────────────────────────── @Get('distritos') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Buscar distritos (requiere ?q= o ?prov=)' }) getDistritos(@Query() query: SearchDto, @Query('prov') prov?: string) { if (query.q) return this.svc.searchDistritos(query); @@ -62,6 +71,7 @@ export class UbigeoController { } @Get('distritos/:codigo') + @Header('Cache-Control', CACHE_1DAY) @ApiOperation({ summary: 'Obtener distrito por código de 6 dígitos' }) @ApiParam({ name: 'codigo', description: 'Código de 6 dígitos', example: '150101' }) getDistrito(@Param('codigo') codigo: string) { diff --git a/src/modules/ubigeo/ubigeo.service.ts b/src/modules/ubigeo/ubigeo.service.ts index b7c6230..d406a4d 100644 --- a/src/modules/ubigeo/ubigeo.service.ts +++ b/src/modules/ubigeo/ubigeo.service.ts @@ -1,203 +1,238 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; import { PrismaService } from '../../prisma/prisma.service'; import { SearchDto } from './dto/ubigeo.dto'; @Injectable() export class UbigeoService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Inject(CACHE_MANAGER) private cache: Cache, + ) {} + + private async cached(key: string, fn: () => Promise): Promise { + const hit = await this.cache.get(key); + if (hit !== undefined && hit !== null) return hit; + const result = await fn(); + await this.cache.set(key, result); + return result; + } // ─── DEPARTAMENTOS ──────────────────────────────────────────────────────────── async getDepartamentos() { - return this.prisma.departamento.findMany({ - orderBy: { nombre: 'asc' }, - select: { codigo: true, nombre: true, latitud: true, longitud: true }, - }); + return this.cached('dep:all', () => + this.prisma.departamento.findMany({ + orderBy: { nombre: 'asc' }, + select: { codigo: true, nombre: true, latitud: true, longitud: true }, + }), + ); } async getDepartamento(codigo: string) { - const dep = await this.prisma.departamento.findUnique({ - where: { codigo }, - include: { - provincias: { - orderBy: { nombre: 'asc' }, - select: { codigo: true, nombre: true, latitud: true, longitud: true }, + return this.cached(`dep:${codigo}`, async () => { + const dep = await this.prisma.departamento.findUnique({ + where: { codigo }, + include: { + provincias: { + orderBy: { nombre: 'asc' }, + select: { codigo: true, nombre: true, latitud: true, longitud: true }, + }, }, - }, + }); + if (!dep) throw new NotFoundException(`Departamento '${codigo}' no encontrado`); + return dep; }); - if (!dep) throw new NotFoundException(`Departamento '${codigo}' no encontrado`); - return dep; } async searchDepartamentos(dto: SearchDto) { - const where = dto.q - ? { nombre: { contains: dto.q, mode: 'insensitive' as const } } - : {}; - return this.prisma.departamento.findMany({ - where, - orderBy: { nombre: 'asc' }, - take: dto.limit, - skip: (dto.page! - 1) * dto.limit!, + const key = `dep:search:${dto.q ?? ''}:${dto.page}:${dto.limit}`; + return this.cached(key, () => { + const where = dto.q + ? { nombre: { contains: dto.q, mode: 'insensitive' as const } } + : {}; + return this.prisma.departamento.findMany({ + where, + orderBy: { nombre: 'asc' }, + take: dto.limit, + skip: (dto.page! - 1) * dto.limit!, + }); }); } // ─── PROVINCIAS ─────────────────────────────────────────────────────────────── async getProvincias(codigoDep?: string) { - const where = codigoDep ? { codigoDep } : {}; - return this.prisma.provincia.findMany({ - where, - orderBy: { nombre: 'asc' }, - include: { - departamento: { select: { codigo: true, nombre: true } }, - }, + return this.cached(`prov:all:${codigoDep ?? ''}`, () => { + const where = codigoDep ? { codigoDep } : {}; + return this.prisma.provincia.findMany({ + where, + orderBy: { nombre: 'asc' }, + include: { + departamento: { select: { codigo: true, nombre: true } }, + }, + }); }); } async getProvincia(codigo: string) { - const prov = await this.prisma.provincia.findUnique({ - where: { codigo }, - include: { - departamento: { select: { codigo: true, nombre: true } }, - distritos: { - orderBy: { nombre: 'asc' }, - select: { codigo: true, nombre: true, capital: true, categoria: true, poblacion: true, latitud: true, longitud: true }, + return this.cached(`prov:${codigo}`, async () => { + const prov = await this.prisma.provincia.findUnique({ + where: { codigo }, + include: { + departamento: { select: { codigo: true, nombre: true } }, + distritos: { + orderBy: { nombre: 'asc' }, + select: { codigo: true, nombre: true, capital: true, categoria: true, poblacion: true, latitud: true, longitud: true }, + }, }, - }, + }); + if (!prov) throw new NotFoundException(`Provincia '${codigo}' no encontrada`); + return prov; }); - if (!prov) throw new NotFoundException(`Provincia '${codigo}' no encontrada`); - return prov; } async searchProvincias(dto: SearchDto) { - const where = dto.q - ? { nombre: { contains: dto.q, mode: 'insensitive' as const } } - : {}; - return this.prisma.provincia.findMany({ - where, - orderBy: { nombre: 'asc' }, - take: dto.limit, - skip: (dto.page! - 1) * dto.limit!, - include: { departamento: { select: { codigo: true, nombre: true } } }, + const key = `prov:search:${dto.q ?? ''}:${dto.page}:${dto.limit}`; + return this.cached(key, () => { + const where = dto.q + ? { nombre: { contains: dto.q, mode: 'insensitive' as const } } + : {}; + return this.prisma.provincia.findMany({ + where, + orderBy: { nombre: 'asc' }, + take: dto.limit, + skip: (dto.page! - 1) * dto.limit!, + include: { departamento: { select: { codigo: true, nombre: true } } }, + }); }); } // ─── DISTRITOS ──────────────────────────────────────────────────────────────── async getDistritos(codigoProv?: string) { - const where = codigoProv ? { codigoProv } : {}; - return this.prisma.distrito.findMany({ - where, - orderBy: { nombre: 'asc' }, - include: { - provincia: { select: { codigo: true, nombre: true } }, - }, + return this.cached(`dist:all:${codigoProv ?? ''}`, () => { + const where = codigoProv ? { codigoProv } : {}; + return this.prisma.distrito.findMany({ + where, + orderBy: { nombre: 'asc' }, + include: { + provincia: { select: { codigo: true, nombre: true } }, + }, + }); }); } async getDistrito(codigo: string) { - const dist = await this.prisma.distrito.findUnique({ - where: { codigo }, - include: { - provincia: { - select: { codigo: true, nombre: true }, - }, - }, - }); - if (!dist) throw new NotFoundException(`Distrito '${codigo}' no encontrado`); - - const dep = await this.prisma.departamento.findUnique({ - where: { codigo: dist.codigoDep }, - select: { codigo: true, nombre: true }, - }); - - return { ...dist, departamento: dep }; - } - - async searchDistritos(dto: SearchDto) { - const where = dto.q - ? { nombre: { contains: dto.q, mode: 'insensitive' as const } } - : {}; - const [data, total] = await Promise.all([ - this.prisma.distrito.findMany({ - where, - orderBy: { nombre: 'asc' }, - take: dto.limit, - skip: (dto.page! - 1) * dto.limit!, + return this.cached(`dist:${codigo}`, async () => { + const dist = await this.prisma.distrito.findUnique({ + where: { codigo }, include: { provincia: { select: { codigo: true, nombre: true } }, }, - }), - this.prisma.distrito.count({ where }), - ]); + }); + if (!dist) throw new NotFoundException(`Distrito '${codigo}' no encontrado`); - // Enriquecer con departamento - const depCodigos = [...new Set(data.map((d) => d.codigoDep))]; - const deps = await this.prisma.departamento.findMany({ - where: { codigo: { in: depCodigos } }, - select: { codigo: true, nombre: true }, + const dep = await this.prisma.departamento.findUnique({ + where: { codigo: dist.codigoDep }, + select: { codigo: true, nombre: true }, + }); + + return { ...dist, departamento: dep }; }); - const depMap = Object.fromEntries(deps.map((d) => [d.codigo, d])); + } - return { - data: data.map((d) => ({ ...d, departamento: depMap[d.codigoDep] })), - meta: { total, page: dto.page, limit: dto.limit, totalPages: Math.ceil(total / dto.limit!) }, - }; + async searchDistritos(dto: SearchDto) { + const key = `dist:search:${dto.q ?? ''}:${dto.page}:${dto.limit}`; + return this.cached(key, async () => { + const where = dto.q + ? { nombre: { contains: dto.q, mode: 'insensitive' as const } } + : {}; + const [data, total] = await Promise.all([ + this.prisma.distrito.findMany({ + where, + orderBy: { nombre: 'asc' }, + take: dto.limit, + skip: (dto.page! - 1) * dto.limit!, + include: { + provincia: { select: { codigo: true, nombre: true } }, + }, + }), + this.prisma.distrito.count({ where }), + ]); + + const depCodigos = [...new Set(data.map((d) => d.codigoDep))]; + const deps = await this.prisma.departamento.findMany({ + where: { codigo: { in: depCodigos } }, + select: { codigo: true, nombre: true }, + }); + const depMap = Object.fromEntries(deps.map((d) => [d.codigo, d])); + + return { + data: data.map((d) => ({ ...d, departamento: depMap[d.codigoDep] })), + meta: { total, page: dto.page, limit: dto.limit, totalPages: Math.ceil(total / dto.limit!) }, + }; + }); } // ─── LOOKUP POR CÓDIGO ──────────────────────────────────────────────────────── async lookup(codigo: string) { - const len = codigo.length; + return this.cached(`lookup:${codigo}`, async () => { + const len = codigo.length; - if (len === 2) { - const dep = await this.prisma.departamento.findUnique({ where: { codigo } }); - if (!dep) throw new NotFoundException(`Código '${codigo}' no encontrado`); - return { tipo: 'departamento', codigo, nombre: dep.nombre }; - } + if (len === 2) { + const dep = await this.prisma.departamento.findUnique({ where: { codigo } }); + if (!dep) throw new NotFoundException(`Código '${codigo}' no encontrado`); + return { tipo: 'departamento', codigo, nombre: dep.nombre }; + } - if (len === 4) { - const prov = await this.prisma.provincia.findUnique({ - where: { codigo }, - include: { departamento: { select: { codigo: true, nombre: true } } }, - }); - if (!prov) throw new NotFoundException(`Código '${codigo}' no encontrado`); - return { tipo: 'provincia', codigo, departamento: prov.departamento.nombre, provincia: prov.nombre }; - } + if (len === 4) { + const prov = await this.prisma.provincia.findUnique({ + where: { codigo }, + include: { departamento: { select: { codigo: true, nombre: true } } }, + }); + if (!prov) throw new NotFoundException(`Código '${codigo}' no encontrado`); + return { tipo: 'provincia', codigo, departamento: prov.departamento.nombre, provincia: prov.nombre }; + } - if (len === 6) { - const dist = await this.prisma.distrito.findUnique({ - where: { codigo }, - include: { provincia: { select: { codigo: true, nombre: true } } }, - }); - if (!dist) throw new NotFoundException(`Código '${codigo}' no encontrado`); - const dep = await this.prisma.departamento.findUnique({ - where: { codigo: dist.codigoDep }, - select: { nombre: true }, - }); - return { - tipo: 'distrito', - codigo, - departamento: dep?.nombre, - provincia: dist.provincia.nombre, - distrito: dist.nombre, - capital: dist.capital, - latitud: dist.latitud, - longitud: dist.longitud, - }; - } + if (len === 6) { + const dist = await this.prisma.distrito.findUnique({ + where: { codigo }, + include: { provincia: { select: { codigo: true, nombre: true } } }, + }); + if (!dist) throw new NotFoundException(`Código '${codigo}' no encontrado`); + const dep = await this.prisma.departamento.findUnique({ + where: { codigo: dist.codigoDep }, + select: { nombre: true }, + }); + return { + tipo: 'distrito', + codigo, + departamento: dep?.nombre, + provincia: dist.provincia.nombre, + distrito: dist.nombre, + capital: dist.capital, + latitud: dist.latitud, + longitud: dist.longitud, + }; + } - throw new NotFoundException(`Código '${codigo}' inválido (debe tener 2, 4 o 6 dígitos)`); + throw new NotFoundException(`Código '${codigo}' inválido (debe tener 2, 4 o 6 dígitos)`); + }); } // ─── STATS ──────────────────────────────────────────────────────────────────── async getStats() { - const [departamentos, provincias, distritos] = await Promise.all([ - this.prisma.departamento.count(), - this.prisma.provincia.count(), - this.prisma.distrito.count(), - ]); - return { departamentos, provincias, distritos, fuente: 'INEI 2025' }; + return this.cached('stats:ubigeo', async () => { + const [departamentos, provincias, distritos] = await Promise.all([ + this.prisma.departamento.count(), + this.prisma.provincia.count(), + this.prisma.distrito.count(), + ]); + return { departamentos, provincias, distritos, fuente: 'INEI 2025' }; + }); } }