feat: add Redis cache, gzip, CI/CD via Gitea self-hosted runner
Some checks failed
CI - Build and Lint / build (push) Failing after 3s
Deploy API-Ubigeo / deploy (push) Failing after 1s

This commit is contained in:
Gianpierre Mio
2026-03-09 23:43:25 -05:00
parent a789d33bee
commit c48dc3cd0b
17 changed files with 567 additions and 248 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.github/.DS_Store vendored

Binary file not shown.

31
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: CI - Build and Lint
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Lint
run: npm run lint --if-present || true
- name: Build
run: npm run build
- name: Verify build output
run: |
[ -d dist ] && echo "✅ Build OK" || (echo "❌ dist not found" && exit 1)

View File

@@ -1,4 +1,4 @@
name: Deploy API Ubigeo name: Deploy API-Ubigeo
on: on:
push: push:
@@ -6,40 +6,25 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy al VPS via SSH - name: Copy source to server
uses: appleboy/ssh-action@v1.0.3 run: |
with: rsync -av --delete \
host: 158.220.106.131 --exclude='.git' \
username: root --exclude='node_modules' \
password: ${{ secrets.VPS_PASSWORD }} --exclude='dist' \
script: | --exclude='.env' \
set -e . /home/deployer/api-ubigeo/
cd /home/deployer/api-ubigeo || (mkdir -p /home/deployer/api-ubigeo && cd /home/deployer/api-ubigeo)
# Pull o clone - name: Build & deploy
if [ -d ".git" ]; then run: bash /home/deployer/deploy-ubigeo.sh
git pull origin main
else
git clone https://github.com/${{ github.repository }} .
fi
# Build imagen - name: Health check
docker build -t ubigeo-api:latest . run: |
sleep 10
# Deploy con compose curl -f https://api-ubigeo.darkcodex.dev/api/v1/health || exit 1
docker compose -f docker-compose.production.yml up -d --force-recreate api
# Ejecutar migraciones
docker exec ubigeo-api npx prisma migrate deploy
# Verificar health
sleep 10
curl -f http://localhost:3200/api/v1/health || exit 1
echo "✅ Deploy exitoso"

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ dist/
*.log *.log
/generated/ /generated/
prisma/seed/*.js prisma/seed/*.js
.DS_Store
**/.DS_Store

View File

@@ -4,8 +4,12 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
COPY prisma ./prisma/ COPY prisma ./prisma/
COPY prisma.config.ts ./
RUN npm ci RUN npm ci
# Generate Prisma client before building
RUN npx prisma generate
COPY . . COPY . .
RUN npm run build RUN npm run build
@@ -16,8 +20,12 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
COPY prisma ./prisma/ COPY prisma ./prisma/
COPY prisma.config.ts ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
# Generate Prisma client in production stage too
RUN npx prisma generate
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001 RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001

View File

@@ -18,6 +18,26 @@ services:
- ubigeo_network - ubigeo_network
- easypanel - easypanel
redis:
image: redis:7-alpine
container_name: ubigeo-redis
restart: unless-stopped
command: >
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: api:
image: ubigeo-api:latest image: ubigeo-api:latest
container_name: ubigeo-api container_name: ubigeo-api
@@ -26,23 +46,21 @@ services:
- "127.0.0.1:3200:3200" - "127.0.0.1:3200:3200"
environment: environment:
DATABASE_URL: "postgresql://ubigeo_user:UbigeoDB2026@postgres:5432/ubigeo_db" DATABASE_URL: "postgresql://ubigeo_user:UbigeoDB2026@postgres:5432/ubigeo_db"
REDIS_URL: "redis://ubigeo-redis:6379"
PORT: 3200 PORT: 3200
NODE_ENV: production NODE_ENV: production
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
labels: redis:
- "traefik.enable=true" condition: service_healthy
- "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"
networks: networks:
- ubigeo_network - ubigeo_network
- easypanel - easypanel
volumes: volumes:
postgres_data: postgres_data:
redis_data:
networks: networks:
ubigeo_network: ubigeo_network:

216
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@keyv/redis": "^5.1.6",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -17,8 +19,12 @@
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@types/compression": "^1.8.1",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.4", "class-validator": "^0.14.4",
"compression": "^1.8.1",
"dotenv": "^17.3.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"pg": "^8.20.0", "pg": "^8.20.0",
"prisma": "^7.4.2", "prisma": "^7.4.2",
@@ -36,7 +42,6 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"dotenv": "^17.3.1",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -724,6 +729,16 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/@chevrotain/cst-dts-gen": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", "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" "@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": { "node_modules/@lukeed/csprng": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
@@ -2171,6 +2209,19 @@
"@tybys/wasm-util": "^0.10.0" "@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": { "node_modules/@nestjs/cli": {
"version": "11.0.16", "version": "11.0.16",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz",
@@ -2937,6 +2988,26 @@
"react-dom": "^18.0.0 || ^19.0.0" "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": { "node_modules/@scarf/scarf": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -3088,18 +3159,26 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@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": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -3146,7 +3225,6 @@
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
@@ -3158,7 +3236,6 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "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==", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@@ -3171,7 +3248,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
@@ -3230,7 +3306,6 @@
"version": "22.19.15", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -3241,14 +3316,12 @@
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
@@ -3265,7 +3338,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -3275,7 +3347,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
@@ -4690,6 +4761,17 @@
"url": "https://dotenvx.com" "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": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "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": ">=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": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -5071,6 +5162,60 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -5386,7 +5531,6 @@
"version": "17.3.1", "version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -6168,6 +6312,16 @@
"node": ">=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": { "node_modules/flatted": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
@@ -6650,6 +6804,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -6681,6 +6847,12 @@
"node": ">=16.9.0" "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -7941,13 +8113,13 @@
} }
}, },
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"json-buffer": "3.0.1" "@keyv/serialize": "^1.1.1"
} }
}, },
"node_modules/leven": { "node_modules/leven": {
@@ -8595,6 +8767,15 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -10706,7 +10887,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": { "node_modules/universalify": {

View File

@@ -24,6 +24,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@keyv/redis": "^5.1.6",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@@ -32,8 +34,12 @@
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@types/compression": "^1.8.1",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.4", "class-validator": "^0.14.4",
"compression": "^1.8.1",
"dotenv": "^17.3.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"pg": "^8.20.0", "pg": "^8.20.0",
"prisma": "^7.4.2", "prisma": "^7.4.2",
@@ -51,7 +57,6 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"dotenv": "^17.3.1",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",

BIN
src/.DS_Store vendored

Binary file not shown.

View File

@@ -1,16 +1,31 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { UbigeoModule } from './modules/ubigeo/ubigeo.module'; import { UbigeoModule } from './modules/ubigeo/ubigeo.module';
import { PaisesModule } from './modules/paises/paises.module'; import { PaisesModule } from './modules/paises/paises.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
const TTL_24H = 24 * 60 * 60 * 1000;
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ ttl: 60000, limit: 200 }]), 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, PrismaModule,
UbigeoModule, UbigeoModule,
PaisesModule, PaisesModule,

View File

@@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as helmet from 'helmet'; import * as helmet from 'helmet';
import compression = require('compression');
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
@@ -12,6 +13,7 @@ async function bootstrap() {
app.enableCors({ origin: '*' }); app.enableCors({ origin: '*' });
app.use(helmet.default()); app.use(helmet.default());
app.use(compression());
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({

BIN
src/modules/.DS_Store vendored

Binary file not shown.

View File

@@ -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 { ApiTags, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
import { PaisesService } from './paises.service'; import { PaisesService } from './paises.service';
const CACHE_1DAY = 'public, max-age=86400, stale-while-revalidate=3600';
@ApiTags('paises') @ApiTags('paises')
@Controller('paises') @Controller('paises')
export class PaisesController { export class PaisesController {
constructor(private readonly svc: PaisesService) {} constructor(private readonly svc: PaisesService) {}
@Get('stats') @Get('stats')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Estadísticas de países' }) @ApiOperation({ summary: 'Estadísticas de países' })
getStats() { return this.svc.getStats(); } getStats() { return this.svc.getStats(); }
@Get('regiones') @Get('regiones')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Listar regiones del mundo' }) @ApiOperation({ summary: 'Listar regiones del mundo' })
getRegiones() { return this.svc.getRegiones(); } getRegiones() { return this.svc.getRegiones(); }
@Get() @Get()
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Listar países (filtrar con ?q= o ?region=)' }) @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: 'q', required: false, description: 'Buscar por nombre o código ISO' })
@ApiQuery({ name: 'region', required: false, description: 'Filtrar por región' }) @ApiQuery({ name: 'region', required: false, description: 'Filtrar por región' })
@@ -24,6 +29,7 @@ export class PaisesController {
} }
@Get(':codigo') @Get(':codigo')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Obtener país por código ISO (PE, PER, etc.)' }) @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' }) @ApiParam({ name: 'codigo', description: 'Código ISO alpha-2 (PE) o alpha-3 (PER)', example: 'PE' })
getPais(@Param('codigo') codigo: string) { getPais(@Param('codigo') codigo: string) {

View File

@@ -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'; import { PrismaService } from '../../prisma/prisma.service';
@Injectable() @Injectable()
export class PaisesService { export class PaisesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
private async cached<T>(key: string, fn: () => Promise<T>): Promise<T> {
const hit = await this.cache.get<T>(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) { async getPaises(search?: string, region?: string) {
const where: any = { activo: true }; const key = `paises:list:${search ?? ''}:${region ?? ''}`;
if (search) { return this.cached(key, () => {
where.OR = [ const where: any = { activo: true };
{ nombre: { contains: search, mode: 'insensitive' } }, if (search) {
{ nombreEn: { contains: search, mode: 'insensitive' } }, where.OR = [
{ codigo: { equals: search.toUpperCase() } }, { nombre: { contains: search, mode: 'insensitive' } },
{ codigoAlpha2: { equals: search.toUpperCase() } }, { nombreEn: { contains: search, mode: 'insensitive' } },
]; { codigo: { equals: search.toUpperCase() } },
} { codigoAlpha2: { equals: search.toUpperCase() } },
if (region) { ];
where.region = { contains: region, mode: 'insensitive' }; }
} if (region) {
return this.prisma.pais.findMany({ where.region = { contains: region, mode: 'insensitive' };
where, }
orderBy: { nombre: 'asc' }, return this.prisma.pais.findMany({
select: { where,
codigo: true, orderBy: { nombre: 'asc' },
codigoAlpha2: true, select: {
nombre: true, codigo: true,
nombreEn: true, codigoAlpha2: true,
capital: true, nombre: true,
region: true, nombreEn: true,
subregion: true, capital: true,
emoji: true, region: true,
latitud: true, subregion: true,
longitud: true, emoji: true,
}, latitud: true,
longitud: true,
},
});
}); });
} }
async getPais(codigo: string) { async getPais(codigo: string) {
const where = codigo.length === 2 return this.cached(`pais:${codigo.toUpperCase()}`, async () => {
? { codigoAlpha2: codigo.toUpperCase() } const where = codigo.length === 2
: { codigo: codigo.toUpperCase() }; ? { codigoAlpha2: codigo.toUpperCase() }
: { codigo: codigo.toUpperCase() };
const pais = await this.prisma.pais.findFirst({ where }); const pais = await this.prisma.pais.findFirst({ where });
if (!pais) throw new NotFoundException(`País '${codigo}' no encontrado`); if (!pais) throw new NotFoundException(`País '${codigo}' no encontrado`);
return pais; return pais;
});
} }
async getRegiones() { async getRegiones() {
const result = await this.prisma.pais.findMany({ return this.cached('paises:regiones', async () => {
where: { activo: true }, const result = await this.prisma.pais.findMany({
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({
where: { activo: true }, where: { activo: true },
distinct: ['region'], distinct: ['region'],
select: { region: true }, select: { region: true },
}), orderBy: { region: 'asc' },
]); });
return { total, regiones: regiones.length }; 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 };
});
} }
} }

View File

@@ -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 { ApiTags, ApiOperation, ApiParam, ApiOkResponse } from '@nestjs/swagger';
import { UbigeoService } from './ubigeo.service'; import { UbigeoService } from './ubigeo.service';
import { SearchDto } from './dto/ubigeo.dto'; import { SearchDto } from './dto/ubigeo.dto';
const CACHE_1DAY = 'public, max-age=86400, stale-while-revalidate=3600';
@ApiTags('ubigeo') @ApiTags('ubigeo')
@Controller('ubigeo') @Controller('ubigeo')
export class UbigeoController { export class UbigeoController {
@@ -10,6 +12,7 @@ export class UbigeoController {
// ─── STATS ───────────────────────────────────────────────────────────────── // ─── STATS ─────────────────────────────────────────────────────────────────
@Get('stats') @Get('stats')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Estadísticas generales del ubigeo' }) @ApiOperation({ summary: 'Estadísticas generales del ubigeo' })
getStats() { getStats() {
return this.svc.getStats(); return this.svc.getStats();
@@ -17,6 +20,7 @@ export class UbigeoController {
// ─── LOOKUP ───────────────────────────────────────────────────────────────── // ─── LOOKUP ─────────────────────────────────────────────────────────────────
@Get('lookup/:codigo') @Get('lookup/:codigo')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Lookup de cualquier código ubigeo (2, 4 o 6 dígitos)' }) @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' }) @ApiParam({ name: 'codigo', description: 'Código ubigeo: 15 | 1501 | 150101', example: '150101' })
lookup(@Param('codigo') codigo: string) { lookup(@Param('codigo') codigo: string) {
@@ -25,6 +29,7 @@ export class UbigeoController {
// ─── DEPARTAMENTOS ────────────────────────────────────────────────────────── // ─── DEPARTAMENTOS ──────────────────────────────────────────────────────────
@Get('departamentos') @Get('departamentos')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Listar todos los departamentos del Perú' }) @ApiOperation({ summary: 'Listar todos los departamentos del Perú' })
getDepartamentos(@Query() query: SearchDto) { getDepartamentos(@Query() query: SearchDto) {
if (query.q) return this.svc.searchDepartamentos(query); if (query.q) return this.svc.searchDepartamentos(query);
@@ -32,6 +37,7 @@ export class UbigeoController {
} }
@Get('departamentos/:codigo') @Get('departamentos/:codigo')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Obtener departamento con sus provincias' }) @ApiOperation({ summary: 'Obtener departamento con sus provincias' })
@ApiParam({ name: 'codigo', description: 'Código de 2 dígitos', example: '15' }) @ApiParam({ name: 'codigo', description: 'Código de 2 dígitos', example: '15' })
getDepartamento(@Param('codigo') codigo: string) { getDepartamento(@Param('codigo') codigo: string) {
@@ -40,6 +46,7 @@ export class UbigeoController {
// ─── PROVINCIAS ───────────────────────────────────────────────────────────── // ─── PROVINCIAS ─────────────────────────────────────────────────────────────
@Get('provincias') @Get('provincias')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Listar provincias (filtrar por departamento con ?dep=15)' }) @ApiOperation({ summary: 'Listar provincias (filtrar por departamento con ?dep=15)' })
getProvincias(@Query() query: SearchDto, @Query('dep') dep?: string) { getProvincias(@Query() query: SearchDto, @Query('dep') dep?: string) {
if (query.q) return this.svc.searchProvincias(query); if (query.q) return this.svc.searchProvincias(query);
@@ -47,6 +54,7 @@ export class UbigeoController {
} }
@Get('provincias/:codigo') @Get('provincias/:codigo')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Obtener provincia con sus distritos' }) @ApiOperation({ summary: 'Obtener provincia con sus distritos' })
@ApiParam({ name: 'codigo', description: 'Código de 4 dígitos', example: '1501' }) @ApiParam({ name: 'codigo', description: 'Código de 4 dígitos', example: '1501' })
getProvincia(@Param('codigo') codigo: string) { getProvincia(@Param('codigo') codigo: string) {
@@ -55,6 +63,7 @@ export class UbigeoController {
// ─── DISTRITOS ────────────────────────────────────────────────────────────── // ─── DISTRITOS ──────────────────────────────────────────────────────────────
@Get('distritos') @Get('distritos')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Buscar distritos (requiere ?q= o ?prov=)' }) @ApiOperation({ summary: 'Buscar distritos (requiere ?q= o ?prov=)' })
getDistritos(@Query() query: SearchDto, @Query('prov') prov?: string) { getDistritos(@Query() query: SearchDto, @Query('prov') prov?: string) {
if (query.q) return this.svc.searchDistritos(query); if (query.q) return this.svc.searchDistritos(query);
@@ -62,6 +71,7 @@ export class UbigeoController {
} }
@Get('distritos/:codigo') @Get('distritos/:codigo')
@Header('Cache-Control', CACHE_1DAY)
@ApiOperation({ summary: 'Obtener distrito por código de 6 dígitos' }) @ApiOperation({ summary: 'Obtener distrito por código de 6 dígitos' })
@ApiParam({ name: 'codigo', description: 'Código de 6 dígitos', example: '150101' }) @ApiParam({ name: 'codigo', description: 'Código de 6 dígitos', example: '150101' })
getDistrito(@Param('codigo') codigo: string) { getDistrito(@Param('codigo') codigo: string) {

View File

@@ -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 { PrismaService } from '../../prisma/prisma.service';
import { SearchDto } from './dto/ubigeo.dto'; import { SearchDto } from './dto/ubigeo.dto';
@Injectable() @Injectable()
export class UbigeoService { export class UbigeoService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
private async cached<T>(key: string, fn: () => Promise<T>): Promise<T> {
const hit = await this.cache.get<T>(key);
if (hit !== undefined && hit !== null) return hit;
const result = await fn();
await this.cache.set(key, result);
return result;
}
// ─── DEPARTAMENTOS ──────────────────────────────────────────────────────────── // ─── DEPARTAMENTOS ────────────────────────────────────────────────────────────
async getDepartamentos() { async getDepartamentos() {
return this.prisma.departamento.findMany({ return this.cached('dep:all', () =>
orderBy: { nombre: 'asc' }, this.prisma.departamento.findMany({
select: { codigo: true, nombre: true, latitud: true, longitud: true }, orderBy: { nombre: 'asc' },
}); select: { codigo: true, nombre: true, latitud: true, longitud: true },
}),
);
} }
async getDepartamento(codigo: string) { async getDepartamento(codigo: string) {
const dep = await this.prisma.departamento.findUnique({ return this.cached(`dep:${codigo}`, async () => {
where: { codigo }, const dep = await this.prisma.departamento.findUnique({
include: { where: { codigo },
provincias: { include: {
orderBy: { nombre: 'asc' }, provincias: {
select: { codigo: true, nombre: true, latitud: true, longitud: true }, 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) { async searchDepartamentos(dto: SearchDto) {
const where = dto.q const key = `dep:search:${dto.q ?? ''}:${dto.page}:${dto.limit}`;
? { nombre: { contains: dto.q, mode: 'insensitive' as const } } return this.cached(key, () => {
: {}; const where = dto.q
return this.prisma.departamento.findMany({ ? { nombre: { contains: dto.q, mode: 'insensitive' as const } }
where, : {};
orderBy: { nombre: 'asc' }, return this.prisma.departamento.findMany({
take: dto.limit, where,
skip: (dto.page! - 1) * dto.limit!, orderBy: { nombre: 'asc' },
take: dto.limit,
skip: (dto.page! - 1) * dto.limit!,
});
}); });
} }
// ─── PROVINCIAS ─────────────────────────────────────────────────────────────── // ─── PROVINCIAS ───────────────────────────────────────────────────────────────
async getProvincias(codigoDep?: string) { async getProvincias(codigoDep?: string) {
const where = codigoDep ? { codigoDep } : {}; return this.cached(`prov:all:${codigoDep ?? ''}`, () => {
return this.prisma.provincia.findMany({ const where = codigoDep ? { codigoDep } : {};
where, return this.prisma.provincia.findMany({
orderBy: { nombre: 'asc' }, where,
include: { orderBy: { nombre: 'asc' },
departamento: { select: { codigo: true, nombre: true } }, include: {
}, departamento: { select: { codigo: true, nombre: true } },
},
});
}); });
} }
async getProvincia(codigo: string) { async getProvincia(codigo: string) {
const prov = await this.prisma.provincia.findUnique({ return this.cached(`prov:${codigo}`, async () => {
where: { codigo }, const prov = await this.prisma.provincia.findUnique({
include: { where: { codigo },
departamento: { select: { codigo: true, nombre: true } }, include: {
distritos: { departamento: { select: { codigo: true, nombre: true } },
orderBy: { nombre: 'asc' }, distritos: {
select: { codigo: true, nombre: true, capital: true, categoria: true, poblacion: true, latitud: true, longitud: true }, 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) { async searchProvincias(dto: SearchDto) {
const where = dto.q const key = `prov:search:${dto.q ?? ''}:${dto.page}:${dto.limit}`;
? { nombre: { contains: dto.q, mode: 'insensitive' as const } } return this.cached(key, () => {
: {}; const where = dto.q
return this.prisma.provincia.findMany({ ? { nombre: { contains: dto.q, mode: 'insensitive' as const } }
where, : {};
orderBy: { nombre: 'asc' }, return this.prisma.provincia.findMany({
take: dto.limit, where,
skip: (dto.page! - 1) * dto.limit!, orderBy: { nombre: 'asc' },
include: { departamento: { select: { codigo: true, nombre: true } } }, take: dto.limit,
skip: (dto.page! - 1) * dto.limit!,
include: { departamento: { select: { codigo: true, nombre: true } } },
});
}); });
} }
// ─── DISTRITOS ──────────────────────────────────────────────────────────────── // ─── DISTRITOS ────────────────────────────────────────────────────────────────
async getDistritos(codigoProv?: string) { async getDistritos(codigoProv?: string) {
const where = codigoProv ? { codigoProv } : {}; return this.cached(`dist:all:${codigoProv ?? ''}`, () => {
return this.prisma.distrito.findMany({ const where = codigoProv ? { codigoProv } : {};
where, return this.prisma.distrito.findMany({
orderBy: { nombre: 'asc' }, where,
include: { orderBy: { nombre: 'asc' },
provincia: { select: { codigo: true, nombre: true } }, include: {
}, provincia: { select: { codigo: true, nombre: true } },
},
});
}); });
} }
async getDistrito(codigo: string) { async getDistrito(codigo: string) {
const dist = await this.prisma.distrito.findUnique({ return this.cached(`dist:${codigo}`, async () => {
where: { codigo }, const dist = await this.prisma.distrito.findUnique({
include: { where: { codigo },
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!,
include: { include: {
provincia: { select: { codigo: true, nombre: true } }, provincia: { select: { codigo: true, nombre: true } },
}, },
}), });
this.prisma.distrito.count({ where }), if (!dist) throw new NotFoundException(`Distrito '${codigo}' no encontrado`);
]);
// Enriquecer con departamento const dep = await this.prisma.departamento.findUnique({
const depCodigos = [...new Set(data.map((d) => d.codigoDep))]; where: { codigo: dist.codigoDep },
const deps = await this.prisma.departamento.findMany({ select: { codigo: true, nombre: true },
where: { codigo: { in: depCodigos } }, });
select: { codigo: true, nombre: true },
return { ...dist, departamento: dep };
}); });
const depMap = Object.fromEntries(deps.map((d) => [d.codigo, d])); }
return { async searchDistritos(dto: SearchDto) {
data: data.map((d) => ({ ...d, departamento: depMap[d.codigoDep] })), const key = `dist:search:${dto.q ?? ''}:${dto.page}:${dto.limit}`;
meta: { total, page: dto.page, limit: dto.limit, totalPages: Math.ceil(total / 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 ──────────────────────────────────────────────────────── // ─── LOOKUP POR CÓDIGO ────────────────────────────────────────────────────────
async lookup(codigo: string) { async lookup(codigo: string) {
const len = codigo.length; return this.cached(`lookup:${codigo}`, async () => {
const len = codigo.length;
if (len === 2) { if (len === 2) {
const dep = await this.prisma.departamento.findUnique({ where: { codigo } }); const dep = await this.prisma.departamento.findUnique({ where: { codigo } });
if (!dep) throw new NotFoundException(`Código '${codigo}' no encontrado`); if (!dep) throw new NotFoundException(`Código '${codigo}' no encontrado`);
return { tipo: 'departamento', codigo, nombre: dep.nombre }; return { tipo: 'departamento', codigo, nombre: dep.nombre };
} }
if (len === 4) { if (len === 4) {
const prov = await this.prisma.provincia.findUnique({ const prov = await this.prisma.provincia.findUnique({
where: { codigo }, where: { codigo },
include: { departamento: { select: { codigo: true, nombre: true } } }, include: { departamento: { select: { codigo: true, nombre: true } } },
}); });
if (!prov) throw new NotFoundException(`Código '${codigo}' no encontrado`); if (!prov) throw new NotFoundException(`Código '${codigo}' no encontrado`);
return { tipo: 'provincia', codigo, departamento: prov.departamento.nombre, provincia: prov.nombre }; return { tipo: 'provincia', codigo, departamento: prov.departamento.nombre, provincia: prov.nombre };
} }
if (len === 6) { if (len === 6) {
const dist = await this.prisma.distrito.findUnique({ const dist = await this.prisma.distrito.findUnique({
where: { codigo }, where: { codigo },
include: { provincia: { select: { codigo: true, nombre: true } } }, include: { provincia: { select: { codigo: true, nombre: true } } },
}); });
if (!dist) throw new NotFoundException(`Código '${codigo}' no encontrado`); if (!dist) throw new NotFoundException(`Código '${codigo}' no encontrado`);
const dep = await this.prisma.departamento.findUnique({ const dep = await this.prisma.departamento.findUnique({
where: { codigo: dist.codigoDep }, where: { codigo: dist.codigoDep },
select: { nombre: true }, select: { nombre: true },
}); });
return { return {
tipo: 'distrito', tipo: 'distrito',
codigo, codigo,
departamento: dep?.nombre, departamento: dep?.nombre,
provincia: dist.provincia.nombre, provincia: dist.provincia.nombre,
distrito: dist.nombre, distrito: dist.nombre,
capital: dist.capital, capital: dist.capital,
latitud: dist.latitud, latitud: dist.latitud,
longitud: dist.longitud, 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 ──────────────────────────────────────────────────────────────────── // ─── STATS ────────────────────────────────────────────────────────────────────
async getStats() { async getStats() {
const [departamentos, provincias, distritos] = await Promise.all([ return this.cached('stats:ubigeo', async () => {
this.prisma.departamento.count(), const [departamentos, provincias, distritos] = await Promise.all([
this.prisma.provincia.count(), this.prisma.departamento.count(),
this.prisma.distrito.count(), this.prisma.provincia.count(),
]); this.prisma.distrito.count(),
return { departamentos, provincias, distritos, fuente: 'INEI 2025' }; ]);
return { departamentos, provincias, distritos, fuente: 'INEI 2025' };
});
} }
} }